From 8d28739ca6c64f391b0ff2f00b0e3a3ff7457375 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 22 Jul 2026 18:49:49 +0200 Subject: [PATCH] feat(admin): expand/collapse all sidebar groups with scrollable nav Co-authored-by: Cursor --- src/app/admin/layout.tsx | 11 +- src/components/admin/admin-mobile-wrapper.tsx | 2 +- src/components/admin/admin-sidebar-nav.tsx | 235 ++++++++++++------ src/messages/en.json | 2 + src/messages/it.json | 2 + src/messages/nl.json | 2 + 6 files changed, 166 insertions(+), 88 deletions(-) diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index fe35eaab..78b3fb4c 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -42,9 +42,7 @@ export default async function AdminLayout({ return ( <> - {csrfToken ? ( - - ) : null} + {csrfToken ? : null} }>
-
+
-
+
); } - diff --git a/src/components/admin/admin-mobile-wrapper.tsx b/src/components/admin/admin-mobile-wrapper.tsx index a695cfa2..d091414e 100644 --- a/src/components/admin/admin-mobile-wrapper.tsx +++ b/src/components/admin/admin-mobile-wrapper.tsx @@ -84,7 +84,7 @@ export function AdminMobileWrapper({ {/* Sidebar */}
pathname === p || pathname.startsWith(`${p}/`), - )) { +function itemIsActive(pathname: string, item: AdminNavItem): boolean { + if ( + item.matchExcludePrefixes?.some( + (p) => pathname === p || pathname.startsWith(`${p}/`), + ) + ) { return false; } if (item.href === "/admin") return pathname === "/admin"; @@ -45,12 +44,23 @@ function readCollapsed(): Record { } } +function persistCollapsed(next: Record) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* ignore quota */ + } +} + export function AdminSidebarNav() { const t = useTranslations("pages.admin.nav"); const pathname = usePathname() ?? ""; const [collapsed, setCollapsed] = useState>({}); const [hydrated, setHydrated] = useState(false); + const groups = useMemo(() => ADMIN_NAV_GROUPS, []); + const allKeys = useMemo(() => groups.map((g) => g.labelKey), [groups]); + useEffect(() => { setCollapsed(readCollapsed()); setHydrated(true); @@ -59,9 +69,9 @@ export function AdminSidebarNav() { // Keep the group that contains the current page open. useEffect(() => { if (!hydrated) return; - const activeKeys = ADMIN_NAV_GROUPS.filter((g) => - groupHasActive(pathname, g.items), - ).map((g) => g.labelKey); + const activeKeys = groups + .filter((g) => groupHasActive(pathname, g.items)) + .map((g) => g.labelKey); if (activeKeys.length === 0) return; setCollapsed((prev) => { let changed = false; @@ -73,91 +83,156 @@ export function AdminSidebarNav() { } } if (!changed) return prev; - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); - } catch { - /* ignore quota */ - } + persistCollapsed(next); return next; }); - }, [pathname, hydrated]); + }, [pathname, hydrated, groups]); const toggle = useCallback((labelKey: string) => { setCollapsed((prev) => { const next = { ...prev, [labelKey]: !prev[labelKey] }; - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); - } catch { - /* ignore quota */ - } + persistCollapsed(next); return next; }); }, []); - const groups = useMemo(() => ADMIN_NAV_GROUPS, []); + const expandAll = useCallback(() => { + const next: Record = {}; + for (const key of allKeys) next[key] = false; + persistCollapsed(next); + setCollapsed(next); + }, [allKeys]); + + const collapseAll = useCallback(() => { + const next: Record = {}; + 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 ( -