feat(sidebar): fleet sidebar standard (header row, user in sidebar, collapsible groups, mini mode, no-flash transitions)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Sonnet 5.5 committed 2026-10-13 16:56:46 +01:00
1 parent b73c23bf55
commit aa55d44406
8 files changed
+654 -225

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
<?php
// Auxiliares da barra lateral do backoffice (incluído por includes/header.php).
// Ficam aqui, e não no HTML, para o menu ser só uma lista de itens: o ativo,
// o aria-current, o title/aria-label (o modo mini só mostra o ícone) e a
// abertura dos grupos decidem-se num sítio.
/** Painel de cada papel: o admin tem o seu, o staff o dele, o cliente a área de sócio. */
function bo_dashboard_path(): string
{
if (is_admin()) return 'views/admin/dashboard.php';
if (is_staff()) return 'views/staff/dashboard.php';
return 'views/user/dashboard.php';
}
/**
* O pedido atual está nesta página? Compara pelo FIM do caminho (e não com ===):
* em dev o site vive num subcaminho do host (/GitTea/repos/fitme3070.pt/...).
* Um caminho terminado em «/» é uma pasta inteira (inclui edit.php, create.php...).
* Aceita uma lista: basta uma coincidir.
*
* @param string|string[] $paths
*/
function bo_nav_active($paths): bool
{
$script = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? ''));
foreach ((array) $paths as $p) {
$p = '/' . ltrim($p, '/');
if (str_ends_with($p, '/') ? str_contains($script, $p) : str_ends_with($script, $p)) {
return true;
}
}
return false;
}
/**
* Uma ligação da barra lateral.
* Opções: match (caminho/s para o «ativo», por omissão o próprio href), mod
* ('sub' | 'sub2' | 'primary'), badge (inteiro; só aparece se > 0).
*/
function bo_nav_link(string $href, string $icon, string $label, array $o = []): string
{
$active = array_key_exists('active', $o) ? (bool) $o['active'] : bo_nav_active($o['match'] ?? strtok($href, '?'));
$mod = isset($o['mod']) ? ' bo-nav-item--' . $o['mod'] : '';
$l = htmlspecialchars($label, ENT_QUOTES, 'UTF-8');
$badge = (int) ($o['badge'] ?? 0);
return '<a class="nav-link bo-nav-item' . $mod . ($active ? ' active' : '') . '" href="' . htmlspecialchars(base_url($href), ENT_QUOTES, 'UTF-8') . '"'
. ' title="' . $l . '" aria-label="' . $l . '"' . ($active ? ' aria-current="page"' : '') . '>'
. '<i class="bi ' . htmlspecialchars($icon, ENT_QUOTES, 'UTF-8') . '" aria-hidden="true"></i>'
. '<span class="bo-nav-item__label">' . $l . '</span>'
. ($badge > 0 ? '<span class="badge bg-danger ms-auto bo-nav-item__badge">' . $badge . '</span>' : '')
. '</a>' . "\n";
}
/**
* Abre um grupo recolhível. O conteúdo fica em buffer até bo_nav_group_end(),
* que precisa de saber se algum item é o ativo (o grupo da página atual abre
* sempre). $sub = subcategoria dentro de outro grupo (recuada).
*/
function bo_nav_group_start(string $key, string $label, string $icon, bool $sub = false): void
{
$GLOBALS['bo_nav_group_stack'][] = [$key, $label, $icon, $sub];
ob_start();
}
/** Fecha o grupo aberto por bo_nav_group_start() e escreve o markup final. */
function bo_nav_group_end(): void
{
$items = (string) ob_get_clean();
[$key, $label, $icon, $sub] = array_pop($GLOBALS['bo_nav_group_stack']);
$hasActive = str_contains($items, 'aria-current="page"');
$id = 'bo-grp-' . $key;
$k = htmlspecialchars($key, ENT_QUOTES, 'UTF-8');
$l = htmlspecialchars($label, ENT_QUOTES, 'UTF-8');
$i = htmlspecialchars($icon, ENT_QUOTES, 'UTF-8');
$short = htmlspecialchars(mb_strtoupper(mb_substr($label, 0, 4)), ENT_QUOTES, 'UTF-8');
echo '<div class="bo-nav-group' . ($sub ? ' bo-nav-group--sub' : '') . ($hasActive ? ' has-active' : '') . '" data-group="' . $k . '">'
. '<button class="bo-nav-group__label" type="button" data-short="' . $short . '" aria-expanded="true"'
. ' aria-controls="' . $id . '" data-group-toggle="' . $k . '" title="' . $l . '" aria-label="' . $l . '">'
. '<i class="bi ' . $i . ' bo-nav-group__icon" aria-hidden="true"></i>'
. '<span class="bo-nav-group__text">' . $l . '</span>'
. '<i class="bi bi-chevron-down bo-nav-group__chevron" aria-hidden="true"></i></button>'
. '<div class="bo-nav-group__body" id="' . $id . '"><div class="bo-nav-group__inner">' . $items . '</div></div>'
. '</div>' . "\n";
}
/** Iniciais para o avatar (até duas letras: primeiro e último nome). */
function bo_initials(string $name): string
{
// Só palavras que começam por letra: «Staff (dev)» dá «SD»... não «S(».
preg_match_all('/\p{L}[\p{L}\p{M}-]*/u', $name, $m);
$parts = $m[0];
if (!$parts) return '?';
$first = mb_substr($parts[0], 0, 1);
$last = count($parts) > 1 ? mb_substr($parts[count($parts) - 1], 0, 1) : '';
return mb_strtoupper($first . $last);
}