Merge pull request 'feat(admin): collapsible sidebar nav groups' (#12) from feat/admin-nav-collapse into main
Local Build and Deploy / deploy (push) Successful in 1m7s
Local Build and Deploy / deploy (push) Successful in 1m7s
Reviewed-on: #12
This commit is contained in:
commit
ef0d673cfe
2 files changed
+165
-36
No files matched your search
@@ -5,12 +5,11 @@ import { getTranslations } from "next-intl/server";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
|
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
|
||||||
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
|
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 { AdminTopbar } from "@/components/admin/admin-topbar";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { requireStaff } from "@/lib/admin/guard";
|
import { requireStaff } from "@/lib/admin/guard";
|
||||||
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
|
|
||||||
import { setCsrfCookie } from "@/lib/foundation/security";
|
import { setCsrfCookie } from "@/lib/foundation/security";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
@@ -94,40 +93,7 @@ async function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
<AdminSidebarNav />
|
||||||
{ADMIN_NAV_GROUPS.map((group, gi) => {
|
|
||||||
const GroupIcon = group.icon;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={group.labelKey}
|
|
||||||
className={
|
|
||||||
gi > 0 ? "mt-4 pt-4 border-t border-[var(--admin-border)]" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5 px-2 pb-2 text-[0.65rem] font-extrabold uppercase tracking-[0.14em] text-[var(--admin-sidebar-text-readable)] opacity-70">
|
|
||||||
<GroupIcon size={14} />
|
|
||||||
<span>{t(group.labelKey)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="px-3 py-3 border-t border-[var(--admin-border)]">
|
<div className="px-3 py-3 border-t border-[var(--admin-border)]">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { AdminNavLink } from "@/components/admin/admin-nav-link";
|
||||||
|
import { ADMIN_NAV_GROUPS, type AdminNavItem } from "@/lib/admin-nav";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "admin-nav-collapsed";
|
||||||
|
|
||||||
|
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";
|
||||||
|
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<string, boolean> {
|
||||||
|
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<string, boolean>;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminSidebarNav() {
|
||||||
|
const t = useTranslations("pages.admin.nav");
|
||||||
|
const pathname = usePathname() ?? "";
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||||
|
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 (
|
||||||
|
<nav className="flex-1 overflow-y-auto px-3 py-4" aria-label={t("overview")}>
|
||||||
|
{groups.map((group, gi) => {
|
||||||
|
const GroupIcon = group.icon;
|
||||||
|
const isCollapsed = Boolean(collapsed[group.labelKey]);
|
||||||
|
const panelId = `admin-nav-${group.labelKey}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.labelKey}
|
||||||
|
className={
|
||||||
|
gi > 0 ? "mt-3 pt-3 border-t border-[var(--admin-border)]" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user