feat(admin): expand/collapse all sidebar groups with scrollable nav
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
e00e9ca2dc
commit
8d28739ca6
6 files changed
+166
-88
No files matched your search
@@ -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" }}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
Reference in new issue
Block a user