219 lines
7.9 KiB
TypeScript
219 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/react";
|
|
import { ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
|
import { useAuth } from "@/context/AuthContext";
|
|
|
|
export type AdminNavItem = {
|
|
label: string;
|
|
href: string;
|
|
icon?: React.ReactNode;
|
|
};
|
|
|
|
export type AdminNavGroup = {
|
|
label: string;
|
|
icon?: React.ReactNode;
|
|
items: AdminNavItem[];
|
|
};
|
|
|
|
function cx(...classes: Array<string | false | undefined | null>) {
|
|
return classes.filter(Boolean).join(" ");
|
|
}
|
|
|
|
function isActiveRoute(pathname: string, href: string) {
|
|
// Exact match for /admin, prefix match for nested pages
|
|
if (href === "/admin") return pathname === "/admin";
|
|
return pathname === href || pathname.startsWith(`${href}/`);
|
|
}
|
|
|
|
function groupHasActiveItem(pathname: string, group: AdminNavGroup) {
|
|
return group.items.some((i) => isActiveRoute(pathname, i.href));
|
|
}
|
|
|
|
export default function AdminLeftNavigation({
|
|
collapsed,
|
|
onToggleCollapsed,
|
|
groups,
|
|
}: {
|
|
collapsed: boolean;
|
|
onToggleCollapsed: () => void;
|
|
groups: AdminNavGroup[];
|
|
}) {
|
|
const pathname = usePathname() || "/admin";
|
|
const { user } = useAuth();
|
|
const identity =
|
|
user?.displayName?.trim() ||
|
|
user?.username?.trim() ||
|
|
user?.email?.trim() ||
|
|
user?.uuid ||
|
|
"Admin";
|
|
const secondary = user?.email && user.email !== identity ? user.email : null;
|
|
|
|
return (
|
|
<aside
|
|
className={cx(
|
|
"fixed inset-y-0 left-0 flex shrink-0 flex-col border-r border-zinc-900 bg-zinc-950/60 backdrop-blur z-40",
|
|
collapsed ? "w-[68px]" : "w-[280px]"
|
|
)}
|
|
>
|
|
{/* Header / Brand */}
|
|
<div className={cx("flex items-center justify-between px-3 py-3", collapsed && "justify-center")}>
|
|
<div className={cx("flex items-center gap-2", collapsed && "hidden")}>
|
|
<div className="h-8 w-8 rounded-lg bg-zinc-900/70 ring-1 ring-zinc-800" />
|
|
<div className="leading-tight">
|
|
<div className="text-xs uppercase tracking-[0.22em] text-zinc-500">
|
|
Battl
|
|
</div>
|
|
<div className="text-sm font-medium text-zinc-100">
|
|
Admin
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onToggleCollapsed}
|
|
className={cx(
|
|
"inline-flex h-9 w-9 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900/40 text-zinc-200 hover:bg-zinc-900/70",
|
|
collapsed && "mx-auto"
|
|
)}
|
|
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
title={collapsed ? "Expand" : "Collapse"}
|
|
>
|
|
{collapsed ? (
|
|
<PanelLeftOpen className="h-4 w-4" />
|
|
) : (
|
|
<PanelLeftClose className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Nav */}
|
|
<nav className="flex-1 overflow-y-auto px-2 pb-3">
|
|
<ul className="space-y-1">
|
|
{groups.map((group) => {
|
|
const defaultOpen = groupHasActiveItem(pathname, group);
|
|
|
|
// In collapsed mode, we render items as a flat icon list (no accordion)
|
|
if (collapsed) {
|
|
return (
|
|
<li key={group.label} className="pt-1">
|
|
<div className="my-2 h-px bg-zinc-900" />
|
|
<div className="flex flex-col gap-1">
|
|
{group.items.map((item) => {
|
|
const active = isActiveRoute(pathname, item.href);
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
title={`${group.label} → ${item.label}`}
|
|
className={cx(
|
|
"flex h-10 items-center justify-center rounded-md border border-transparent",
|
|
active
|
|
? "bg-white/5 text-white ring-1 ring-zinc-800"
|
|
: "text-zinc-400 hover:bg-white/5 hover:text-zinc-200"
|
|
)}
|
|
>
|
|
<span className="inline-flex">{item.icon}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<li key={group.label}>
|
|
<Disclosure defaultOpen={defaultOpen}>
|
|
{({ open }) => (
|
|
<>
|
|
<DisclosureButton
|
|
className={cx(
|
|
"group flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-xs font-semibold uppercase tracking-[0.18em]",
|
|
open ? "bg-white/5 text-zinc-100" : "text-zinc-400 hover:bg-white/5 hover:text-zinc-200"
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<span className="inline-flex text-zinc-400 group-hover:text-zinc-200">
|
|
{group.icon}
|
|
</span>
|
|
{group.label}
|
|
</span>
|
|
|
|
<ChevronRight
|
|
className={cx(
|
|
"h-4 w-4 text-zinc-500 transition-transform",
|
|
open && "rotate-90"
|
|
)}
|
|
/>
|
|
</DisclosureButton>
|
|
|
|
<DisclosurePanel className="mt-1 space-y-1 pl-1">
|
|
{group.items.map((item) => {
|
|
const active = isActiveRoute(pathname, item.href);
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
className={cx(
|
|
"flex items-center gap-2 rounded-md px-2 py-2 text-sm",
|
|
active
|
|
? "bg-white/5 text-white ring-1 ring-zinc-800"
|
|
: "text-zinc-300 hover:bg-white/5 hover:text-white"
|
|
)}
|
|
>
|
|
<span className="inline-flex h-5 w-5 items-center justify-center text-zinc-400">
|
|
{item.icon}
|
|
</span>
|
|
<span className="truncate">{item.label}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</DisclosurePanel>
|
|
</>
|
|
)}
|
|
</Disclosure>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
|
|
{/* Footer */}
|
|
<div className={cx("border-t border-zinc-900 p-3", collapsed && "px-2")}>
|
|
<Link
|
|
href="/builder"
|
|
className={cx(
|
|
"mb-3 inline-flex w-full items-center justify-center rounded-md border border-zinc-800 bg-zinc-900/40 px-3 py-2 text-xs font-medium text-zinc-200 hover:bg-zinc-900/70",
|
|
collapsed && "px-2"
|
|
)}
|
|
title="Back to Builder"
|
|
>
|
|
{collapsed ? "↩" : "Back to Builder"}
|
|
</Link>
|
|
<div
|
|
className={cx(
|
|
"flex items-center gap-3 rounded-md bg-zinc-900/30 px-3 py-2 ring-1 ring-zinc-800",
|
|
collapsed && "justify-center px-2"
|
|
)}
|
|
title={secondary ?? identity}
|
|
>
|
|
<div className="h-8 w-8 rounded-full bg-zinc-900 ring-1 ring-zinc-800" />
|
|
{!collapsed && (
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm text-zinc-200">{identity}</div>
|
|
<div className="truncate text-xs text-zinc-500">
|
|
{secondary ?? "Authenticated user"}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|