web/src/components/layout.tsx
4.2 KB · sha256:caf841de7a8189098f1020f57a55f3f03c205bdf41e28afc307512c942e0965d
import { Link, useRouterState } from "@tanstack/react-router";
import {
Boxes,
Compass,
LayoutDashboard,
LogOut,
Moon,
Sun,
Users,
Shield,
type LucideIcon,
} from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "../lib/cn";
import { useTheme } from "../lib/theme";
import { useAuth } from "../lib/auth";
import { Logo, PlayerAvatar } from "./ui";
interface NavItem {
to: string;
label: string;
icon: LucideIcon;
exact?: boolean;
}
const NAV: NavItem[] = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, exact: true },
{ to: "/players", label: "Players", icon: Users },
{ to: "/groups", label: "Groups", icon: Shield },
{ to: "/bundles", label: "Bundles", icon: Boxes },
{ to: "/tracks", label: "Tracks", icon: Compass },
];
export function Sidebar() {
const { location } = useRouterState();
return (
<aside className="bg-surface border-border flex w-64 shrink-0 flex-col border-r">
<div className="border-border flex h-16 items-center border-b px-5">
<Link to="/" className="flex items-center gap-2.5">
<Logo size={32} />
<div className="leading-tight">
<div className="text-fg text-sm font-semibold tracking-tight">
Ward
</div>
<div className="text-fg-subtle text-[11px]">Ward admin</div>
</div>
</Link>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{NAV.map((item) => {
const active = item.exact
? location.pathname === item.to
: location.pathname === item.to ||
location.pathname.startsWith(item.to + "/");
const Icon = item.icon;
return (
<Link
key={item.to}
to={item.to}
className={cn(
"group flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition",
active
? "bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-200"
: "text-fg-muted hover:text-fg hover:bg-surface-3",
)}
>
<Icon
size={18}
className={cn(
"shrink-0",
active ? "text-brand-600 dark:text-brand-300" : "",
)}
/>
{item.label}
</Link>
);
})}
</nav>
<SidebarFooter />
</aside>
);
}
function SidebarFooter() {
const { whoami, signOut } = useAuth();
return (
<div className="border-border border-t p-3">
{whoami && (
<div className="hover:bg-surface-3 flex items-center gap-3 rounded-lg p-2 transition">
<PlayerAvatar uuid={whoami.uuid} name={whoami.name} size={32} />
<div className="min-w-0 flex-1">
<div className="text-fg truncate text-sm font-medium">
{whoami.name}
</div>
<div className="text-fg-subtle text-[11px]">Ward · v0.2</div>
</div>
<button
type="button"
onClick={signOut}
aria-label="Sign out"
className="text-fg-subtle hover:text-red-600 dark:hover:text-red-400 inline-flex h-8 w-8 items-center justify-center rounded-md transition"
>
<LogOut size={14} />
</button>
</div>
)}
</div>
);
}
export function Header({ children }: { children?: ReactNode }) {
const { theme, toggle } = useTheme();
return (
<header className="bg-surface border-border sticky top-0 z-10 flex h-16 items-center justify-between gap-4 border-b px-6">
<div className="min-w-0 flex-1">{children}</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggle}
aria-label="Toggle theme"
className="text-fg-muted hover:text-fg hover:bg-surface-3 inline-flex h-9 w-9 items-center justify-center rounded-lg transition"
>
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</button>
</div>
</header>
);
}
export function PageShell({ children }: { children: ReactNode }) {
return <div className="px-6 py-8 lg:px-10">{children}</div>;
}