From 518ed14a83b4e5f113d7a555ee8ecc1e4c76cc99 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 18 Jul 2026 22:11:16 +0200 Subject: [PATCH] Add collapsible admin sidebar nav groups with persistence. Category headers toggle open/closed; state is stored in localStorage and the active route group stays expanded. Co-authored-by: Cursor --- src/app/admin/layout.tsx | 38 +---- src/components/admin/admin-sidebar-nav.tsx | 163 +++++++++++++++++++++ 2 files changed, 165 insertions(+), 36 deletions(-) create mode 100644 src/components/admin/admin-sidebar-nav.tsx diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 9508fc45..926fa3b2 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -5,12 +5,11 @@ import { getTranslations } from "next-intl/server"; import type { ReactNode } from "react"; import { AdminHubChrome } from "@/components/admin/admin-hub-chrome"; import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper"; -import { AdminNavLink } from "@/components/admin/admin-nav-link"; +import { AdminSidebarNav } from "@/components/admin/admin-sidebar-nav"; import { AdminTopbar } from "@/components/admin/admin-topbar"; import { LanguageSwitcher } from "@/components/language-switcher"; import { ThemeSwitcher } from "@/components/theme-switcher"; import { requireStaff } from "@/lib/admin/guard"; -import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav"; import { setCsrfCookie } from "@/lib/foundation/security"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; @@ -94,40 +93,7 @@ async function Sidebar({ - +
pathname === p || pathname.startsWith(`${p}/`), + )) { + return false; + } + if (item.href === "/admin") return pathname === "/admin"; + if (item.matchPrefixes?.length) { + return item.matchPrefixes.some( + (p) => pathname === p || pathname.startsWith(`${p}/`), + ); + } + return pathname === item.href || pathname.startsWith(`${item.href}/`); +} + +function groupHasActive(pathname: string, items: AdminNavItem[]): boolean { + return items.some((item) => itemIsActive(pathname, item)); +} + +function readCollapsed(): Record { + if (typeof window === "undefined") return {}; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") return {}; + return parsed as Record; + } catch { + return {}; + } +} + +export function AdminSidebarNav() { + const t = useTranslations("pages.admin.nav"); + const pathname = usePathname() ?? ""; + const [collapsed, setCollapsed] = useState>({}); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + setCollapsed(readCollapsed()); + setHydrated(true); + }, []); + + // 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); + if (activeKeys.length === 0) return; + setCollapsed((prev) => { + let changed = false; + const next = { ...prev }; + for (const key of activeKeys) { + if (next[key]) { + next[key] = false; + changed = true; + } + } + if (!changed) return prev; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* ignore quota */ + } + return next; + }); + }, [pathname, hydrated]); + + const toggle = useCallback((labelKey: string) => { + setCollapsed((prev) => { + const next = { ...prev, [labelKey]: !prev[labelKey] }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* ignore quota */ + } + return next; + }); + }, []); + + const groups = useMemo(() => ADMIN_NAV_GROUPS, []); + + return ( + + ); +}