feat(admin): expand/collapse all sidebar groups with scrollable nav
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m6s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-22 18:49:49 +02:00
1 parent e00e9ca2dc
commit 8d28739ca6
6 files changed
+166 -88

No files matched your search

+4 -7
View File
@@ -42,9 +42,7 @@ export default async function AdminLayout({
return ( return (
<> <>
{csrfToken ? ( {csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
<meta name="csrf-token" content={csrfToken} />
) : null}
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}> <AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
<div <div
data-admin data-admin
@@ -72,9 +70,9 @@ async function Sidebar({
return ( return (
<aside <aside
data-admin data-admin
className="max-lg:static sticky top-0 self-start h-full lg:h-screen overflow-y-auto bg-[var(--admin-sidebar-background)] text-[var(--admin-sidebar-text-readable)] shadow-xl flex flex-col" className="max-lg:static sticky top-0 flex h-full max-h-screen min-h-0 w-full flex-col self-start overflow-hidden bg-[var(--admin-sidebar-background)] text-[var(--admin-sidebar-text-readable)] shadow-xl lg:h-screen"
> >
<div className="flex items-center gap-3 px-4 py-5 border-b border-[var(--admin-border)]"> <div className="flex shrink-0 items-center gap-3 border-b border-[var(--admin-border)] px-4 py-5">
<span <span
className="flex-none w-10 h-10 rounded-xl grid place-items-center font-extrabold text-base text-[var(--admin-accent-foreground)] bg-[var(--admin-accent)] shadow-lg shadow-[var(--admin-accent)]/20" className="flex-none w-10 h-10 rounded-xl grid place-items-center font-extrabold text-base text-[var(--admin-accent-foreground)] bg-[var(--admin-accent)] shadow-lg shadow-[var(--admin-accent)]/20"
aria-hidden aria-hidden
@@ -97,7 +95,7 @@ async function Sidebar({
<AdminSidebarNav /> <AdminSidebarNav />
<div className="px-3 py-3 border-t border-[var(--admin-border)]"> <div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3">
<Link <Link
href="/" href="/"
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[var(--admin-sidebar-muted-readable)] text-xs font-medium hover:text-[var(--admin-sidebar-text-readable)] hover:bg-[var(--admin-accent)]/10 transition-all duration-150 no-underline" className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[var(--admin-sidebar-muted-readable)] text-xs font-medium hover:text-[var(--admin-sidebar-text-readable)] hover:bg-[var(--admin-accent)]/10 transition-all duration-150 no-underline"
@@ -109,4 +107,3 @@ async function Sidebar({
</aside> </aside>
); );
} }
@@ -84,7 +84,7 @@ export function AdminMobileWrapper({
{/* Sidebar */} {/* Sidebar */}
<div <div
className={`fixed inset-y-0 left-0 z-50 w-[260px] lg:!relative lg:!translate-x-0 lg:!min-h-screen ${ className={`fixed inset-y-0 left-0 z-50 flex h-full w-[260px] flex-col overflow-hidden lg:!relative lg:!min-h-screen lg:!translate-x-0 ${
open ? "translate-x-0" : "-translate-x-full" open ? "translate-x-0" : "-translate-x-full"
}`} }`}
style={{ transition: "transform 300ms ease-in-out" }} style={{ transition: "transform 300ms ease-in-out" }}
+155 -80
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { ChevronDown } from "lucide-react"; import { ChevronDown, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
@@ -10,13 +10,12 @@ import { cn } from "@/lib/utils";
const STORAGE_KEY = "admin-nav-collapsed"; const STORAGE_KEY = "admin-nav-collapsed";
function itemIsActive( function itemIsActive(pathname: string, item: AdminNavItem): boolean {
pathname: string, if (
item: AdminNavItem, item.matchExcludePrefixes?.some(
): boolean { (p) => pathname === p || pathname.startsWith(`${p}/`),
if (item.matchExcludePrefixes?.some( )
(p) => pathname === p || pathname.startsWith(`${p}/`), ) {
)) {
return false; return false;
} }
if (item.href === "/admin") return pathname === "/admin"; if (item.href === "/admin") return pathname === "/admin";
@@ -45,12 +44,23 @@ function readCollapsed(): Record<string, boolean> {
} }
} }
function persistCollapsed(next: Record<string, boolean>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
}
export function AdminSidebarNav() { export function AdminSidebarNav() {
const t = useTranslations("pages.admin.nav"); const t = useTranslations("pages.admin.nav");
const pathname = usePathname() ?? ""; const pathname = usePathname() ?? "";
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({}); const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [hydrated, setHydrated] = useState(false); const [hydrated, setHydrated] = useState(false);
const groups = useMemo(() => ADMIN_NAV_GROUPS, []);
const allKeys = useMemo(() => groups.map((g) => g.labelKey), [groups]);
useEffect(() => { useEffect(() => {
setCollapsed(readCollapsed()); setCollapsed(readCollapsed());
setHydrated(true); setHydrated(true);
@@ -59,9 +69,9 @@ export function AdminSidebarNav() {
// Keep the group that contains the current page open. // Keep the group that contains the current page open.
useEffect(() => { useEffect(() => {
if (!hydrated) return; if (!hydrated) return;
const activeKeys = ADMIN_NAV_GROUPS.filter((g) => const activeKeys = groups
groupHasActive(pathname, g.items), .filter((g) => groupHasActive(pathname, g.items))
).map((g) => g.labelKey); .map((g) => g.labelKey);
if (activeKeys.length === 0) return; if (activeKeys.length === 0) return;
setCollapsed((prev) => { setCollapsed((prev) => {
let changed = false; let changed = false;
@@ -73,91 +83,156 @@ export function AdminSidebarNav() {
} }
} }
if (!changed) return prev; if (!changed) return prev;
try { persistCollapsed(next);
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
return next; return next;
}); });
}, [pathname, hydrated]); }, [pathname, hydrated, groups]);
const toggle = useCallback((labelKey: string) => { const toggle = useCallback((labelKey: string) => {
setCollapsed((prev) => { setCollapsed((prev) => {
const next = { ...prev, [labelKey]: !prev[labelKey] }; const next = { ...prev, [labelKey]: !prev[labelKey] };
try { persistCollapsed(next);
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
return next; return next;
}); });
}, []); }, []);
const groups = useMemo(() => ADMIN_NAV_GROUPS, []); const expandAll = useCallback(() => {
const next: Record<string, boolean> = {};
for (const key of allKeys) next[key] = false;
persistCollapsed(next);
setCollapsed(next);
}, [allKeys]);
const collapseAll = useCallback(() => {
const next: Record<string, boolean> = {};
for (const key of allKeys) next[key] = true;
// Keep the active route's group open so you never lose context.
for (const g of groups) {
if (groupHasActive(pathname, g.items)) next[g.labelKey] = false;
}
persistCollapsed(next);
setCollapsed(next);
}, [allKeys, groups, pathname]);
const allExpanded = hydrated && allKeys.every((k) => !collapsed[k]);
const canCollapseMore =
hydrated &&
allKeys.some((k) => {
const g = groups.find((x) => x.labelKey === k);
if (g && groupHasActive(pathname, g.items)) return false;
return !collapsed[k];
});
return ( return (
<nav className="flex-1 overflow-y-auto px-3 py-4" aria-label={t("overview")}> <nav
{groups.map((group, gi) => { className="flex min-h-0 flex-1 flex-col overflow-hidden"
const GroupIcon = group.icon; aria-label={t("overview")}
const isCollapsed = Boolean(collapsed[group.labelKey]); >
const panelId = `admin-nav-${group.labelKey}`; <div className="flex shrink-0 items-center gap-1 border-b border-[var(--admin-border)] px-3 py-2">
<button
type="button"
onClick={expandAll}
disabled={allExpanded}
title={t("expandAll")}
aria-label={t("expandAll")}
className={cn(
"inline-flex flex-1 items-center justify-center gap-1 rounded-md px-2 py-1.5",
"text-[0.65rem] font-bold uppercase tracking-wide",
"text-[var(--admin-sidebar-muted-readable)]",
"hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-sidebar-text-readable)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
"disabled:pointer-events-none disabled:opacity-40",
"transition-colors",
)}
>
<ChevronsUpDown size={14} aria-hidden />
<span className="truncate">{t("expandAll")}</span>
</button>
<button
type="button"
onClick={collapseAll}
disabled={!canCollapseMore}
title={t("collapseAll")}
aria-label={t("collapseAll")}
className={cn(
"inline-flex flex-1 items-center justify-center gap-1 rounded-md px-2 py-1.5",
"text-[0.65rem] font-bold uppercase tracking-wide",
"text-[var(--admin-sidebar-muted-readable)]",
"hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-sidebar-text-readable)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
"disabled:pointer-events-none disabled:opacity-40",
"transition-colors",
)}
>
<ChevronsDownUp size={14} aria-hidden />
<span className="truncate">{t("collapseAll")}</span>
</button>
</div>
return ( <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3">
<div {groups.map((group, gi) => {
key={group.labelKey} const GroupIcon = group.icon;
className={ const isCollapsed = Boolean(collapsed[group.labelKey]);
gi > 0 ? "mt-3 pt-3 border-t border-[var(--admin-border)]" : "" const panelId = `admin-nav-${group.labelKey}`;
}
> return (
<button
type="button"
onClick={() => toggle(group.labelKey)}
aria-expanded={!isCollapsed}
aria-controls={panelId}
className={cn(
"flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left",
"text-[0.65rem] font-extrabold uppercase tracking-[0.14em]",
"text-[var(--admin-sidebar-text-readable)] opacity-70",
"hover:opacity-100 hover:bg-[var(--admin-accent)]/10",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
"transition-colors",
)}
>
<GroupIcon size={14} className="shrink-0" />
<span className="min-w-0 flex-1 truncate">{t(group.labelKey)}</span>
<ChevronDown
size={14}
className={cn(
"shrink-0 opacity-80 transition-transform duration-200",
isCollapsed && "-rotate-90",
)}
aria-hidden
/>
</button>
<div <div
id={panelId} key={group.labelKey}
hidden={isCollapsed} className={
className="mt-1 space-y-0.5" gi > 0 ? "mt-3 border-t border-[var(--admin-border)] pt-3" : ""
}
> >
{group.items.map((item) => { <button
const ItemIcon = item.icon; type="button"
return ( onClick={() => toggle(group.labelKey)}
<AdminNavLink aria-expanded={!isCollapsed}
key={`${group.labelKey}-${item.href}`} aria-controls={panelId}
href={item.href} className={cn(
matchPrefixes={item.matchPrefixes} "flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left",
matchExcludePrefixes={item.matchExcludePrefixes} "text-[0.65rem] font-extrabold uppercase tracking-[0.14em]",
icon={<ItemIcon size={16} />} "text-[var(--admin-sidebar-text-readable)] opacity-70",
> "hover:opacity-100 hover:bg-[var(--admin-accent)]/10",
{t(item.labelKey)} "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
</AdminNavLink> "transition-colors",
); )}
})} >
<GroupIcon size={14} className="shrink-0" />
<span className="min-w-0 flex-1 truncate">
{t(group.labelKey)}
</span>
<ChevronDown
size={14}
className={cn(
"shrink-0 opacity-80 transition-transform duration-200",
isCollapsed && "-rotate-90",
)}
aria-hidden
/>
</button>
<div
id={panelId}
hidden={isCollapsed}
className="mt-1 space-y-0.5"
>
{group.items.map((item) => {
const ItemIcon = item.icon;
return (
<AdminNavLink
key={`${group.labelKey}-${item.href}`}
href={item.href}
matchPrefixes={item.matchPrefixes}
matchExcludePrefixes={item.matchExcludePrefixes}
icon={<ItemIcon size={16} />}
>
{t(item.labelKey)}
</AdminNavLink>
);
})}
</div>
</div> </div>
</div> );
); })}
})} </div>
</nav> </nav>
); );
} }
+2
View File
@@ -1080,6 +1080,8 @@
"import": "Import", "import": "Import",
"settings": "Settings", "settings": "Settings",
"backToSite": "Back to site", "backToSite": "Back to site",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"housekeeping": "Housekeeping", "housekeeping": "Housekeeping",
"hotelManagementPanel": "Hotel management panel", "hotelManagementPanel": "Hotel management panel",
"rankLabel": "Rank {rank}", "rankLabel": "Rank {rank}",
+2
View File
@@ -1010,6 +1010,8 @@
"import": "Importa", "import": "Importa",
"settings": "Impostazioni", "settings": "Impostazioni",
"backToSite": "Torna al sito", "backToSite": "Torna al sito",
"expandAll": "Apri tutte",
"collapseAll": "Chiudi tutte",
"hotelManagementPanel": "Pannello di gestione hotel", "hotelManagementPanel": "Pannello di gestione hotel",
"rankLabel": "Grado {rank}", "rankLabel": "Grado {rank}",
"hubNewsMedia": "Notizie e media", "hubNewsMedia": "Notizie e media",
+2
View File
@@ -1054,6 +1054,8 @@
"import": "Importeren", "import": "Importeren",
"settings": "Instellingen", "settings": "Instellingen",
"backToSite": "Terug naar site", "backToSite": "Terug naar site",
"expandAll": "Alles openen",
"collapseAll": "Alles sluiten",
"housekeeping": "Housekeeping", "housekeeping": "Housekeeping",
"hotelManagementPanel": "Hotelbeheerpaneel", "hotelManagementPanel": "Hotelbeheerpaneel",
"rankLabel": "Rang {rank}", "rankLabel": "Rang {rank}",