TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import Link from "next/link";5import { usePathname } from "next/navigation";6import { Activity, Award, BarChart3, Coins, FlaskConical, Gamepad2, Gift, HeartPulse, LayoutDashboard, LogOut, Menu, Settings, ShieldAlert, Target, Users, UsersRound, X } from "lucide-react";7import { SpinzaMark } from "@/components/brand/logo";8import { cn } from "@/lib/utils";9import { useAdmin } from "./store";10import { Pill } from "./primitives";1112interface NavItem {13 href: string;14 label: string;15 icon: React.ComponentType<{ className?: string }>;16}1718const SECTIONS: { title: string; items: NavItem[] }[] = [19 { title: "Overview", items: [{ href: "/admin", label: "Dashboard", icon: LayoutDashboard }] },20 {21 title: "Operations",22 items: [23 { href: "/admin/users", label: "Users", icon: Users },24 { href: "/admin/games", label: "Games", icon: Gamepad2 },25 { href: "/admin/simulator", label: "Game Simulator", icon: FlaskConical },26 ],27 },28 {29 title: "Insights",30 items: [31 { href: "/admin/economy", label: "Economy", icon: Coins },32 { href: "/admin/analytics/games", label: "Game Analytics", icon: BarChart3 },33 { href: "/admin/analytics/players", label: "Player Analytics", icon: UsersRound },34 ],35 },36 {37 title: "Progression",38 items: [39 { href: "/admin/missions", label: "Missions", icon: Target },40 { href: "/admin/achievements", label: "Achievements", icon: Award },41 { href: "/admin/rewards", label: "Daily Rewards", icon: Gift },42 ],43 },44 {45 title: "Platform",46 items: [47 { href: "/admin/system", label: "System Health", icon: HeartPulse },48 { href: "/admin/security", label: "Security", icon: ShieldAlert },49 { href: "/admin/settings", label: "Settings", icon: Settings },50 ],51 },52];5354const ALL_ITEMS = SECTIONS.flatMap((s) => s.items);5556function isActive(path: string, href: string) {57 return href === "/admin" ? path === "/admin" : path === href || path.startsWith(href + "/");58}5960function currentTitle(path: string): string {61 const hit = ALL_ITEMS.filter((i) => isActive(path, i.href)).sort((a, b) => b.href.length - a.href.length)[0];62 if (!hit) return "Admin";63 if (path.startsWith("/admin/users/") && path !== "/admin/users") return "User detail";64 if (path.startsWith("/admin/games/") && path !== "/admin/games") return "Game detail";65 return hit.label;66}6768export function AdminShell({ children }: { children: React.ReactNode }) {69 const path = usePathname() ?? "/admin";70 const [open, setOpen] = React.useState(false);71 const admin = useAdmin((s) => s.admin);72 const maintenance = useAdmin((s) => s.maintenance);73 const node = useAdmin((s) => s.node);74 const version = useAdmin((s) => s.version);75 const signOut = useAdmin((s) => s.signOut);76 const loadStatus = useAdmin((s) => s.loadStatus);7778 React.useEffect(() => {79 const t = setInterval(() => {80 if (document.visibilityState === "visible") void loadStatus();81 }, 60_000);82 return () => clearInterval(t);83 }, [loadStatus]);8485 const nav = (86 <nav className="flex flex-1 flex-col gap-4 overflow-y-auto px-3 py-3">87 {SECTIONS.map((s) => (88 <div key={s.title}>89 <div className="eyebrow mb-1 px-2 text-[10px]">{s.title}</div>90 <ul className="flex flex-col gap-0.5">91 {s.items.map((it) => {92 const active = isActive(path, it.href);93 return (94 <li key={it.href}>95 <Link href={it.href} onClick={() => setOpen(false)} aria-current={active ? "page" : undefined} className={cn("flex items-center gap-2.5 rounded-sm px-2 py-1.5 text-[13px] font-medium transition-colors", active ? "bg-surface-3 text-fg" : "text-fg-3 hover:bg-surface-2 hover:text-fg-2")}>96 <it.icon className={cn("h-4 w-4 shrink-0", active ? "text-accent-2" : "text-fg-4")} />97 {it.label}98 </Link>99 </li>100 );101 })}102 </ul>103 </div>104 ))}105 </nav>106 );107108 return (109 <div className="min-h-dvh bg-bg lg:grid lg:grid-cols-[224px_1fr]">110 {/* Sidebar (desktop) */}111 <aside className="sticky top-0 hidden h-dvh flex-col border-r border-line bg-bg-1/70 lg:flex">112 <Link href="/admin" className="flex h-14 items-center gap-2 border-b border-line px-4">113 <SpinzaMark className="h-6 w-6" glow={false} />114 <span className="text-[15px] font-semibold tracking-tight">115 SPIN<span className="text-accent">ZA</span>116 </span>117 <span className="ml-1 rounded-xs border border-line px-1.5 py-px text-[10px] font-bold uppercase tracking-wider text-fg-3">Admin</span>118 </Link>119 {nav}120 <div className="border-t border-line px-4 py-3 text-[11px] leading-relaxed text-fg-4">121 Internal console. Virtual credits only — SC have no cash value.122 <div className="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-fg-4">123 <Activity className="h-3 w-3" /> {node ?? "—"} · v{version ?? "—"}124 </div>125 </div>126 </aside>127128 {/* Mobile drawer */}129 {open ? (130 <div className="fixed inset-0 lg:hidden" style={{ zIndex: "var(--z-sheet)" }}>131 <div className="absolute inset-0 bg-black/70" onClick={() => setOpen(false)} />132 <div className="absolute inset-y-0 left-0 flex w-[260px] flex-col bg-bg-1 shadow-2xl">133 <div className="flex h-14 items-center justify-between border-b border-line px-4">134 <span className="text-[15px] font-semibold tracking-tight">135 SPIN<span className="text-accent">ZA</span> <span className="text-fg-3">Admin</span>136 </span>137 <button onClick={() => setOpen(false)} className="tap grid place-items-center rounded-md text-fg-3 hover:text-fg" aria-label="Close menu">138 <X className="h-5 w-5" />139 </button>140 </div>141 {nav}142 </div>143 </div>144 ) : null}145146 <div className="flex min-h-dvh min-w-0 flex-col">147 <header className="sticky top-0 border-b border-line bg-bg-1/80 backdrop-blur" style={{ zIndex: "var(--z-nav)", paddingTop: "var(--safe-top)" }}>148 <div className="flex h-14 items-center justify-between gap-3 px-4 lg:px-6">149 <div className="flex min-w-0 items-center gap-3">150 <button onClick={() => setOpen(true)} className="tap -ml-2 grid place-items-center rounded-md text-fg-3 hover:text-fg lg:hidden" aria-label="Open menu">151 <Menu className="h-5 w-5" />152 </button>153 <div className="truncate text-[13px] font-semibold text-fg">{currentTitle(path)}</div>154 </div>155 <div className="flex items-center gap-2 sm:gap-3">156 {maintenance ? (157 maintenance.enabled ? (158 <Pill tone="danger" dot>159 Maintenance160 </Pill>161 ) : (162 <Pill tone="success" dot>163 Live164 </Pill>165 )166 ) : (167 <Pill tone="muted">…</Pill>168 )}169 <span className="hidden font-mono text-[11px] text-fg-3 md:inline">170 {node ?? "—"} · v{version ?? "—"}171 </span>172 <span className="hidden items-center gap-2 rounded-full border border-line bg-surface px-2.5 py-1 text-[12px] sm:inline-flex">173 <span className="grid h-5 w-5 place-items-center rounded-full bg-accent text-[10px] font-bold text-bg">{admin?.username.slice(0, 1).toUpperCase()}</span>174 <span className="font-medium">{admin?.username}</span>175 <span className="text-fg-4">{admin?.role}</span>176 </span>177 <button onClick={() => void signOut()} className="tap grid place-items-center rounded-md text-fg-3 hover:text-fg focus-ring" aria-label="Log out" title="Log out">178 <LogOut className="h-4 w-4" />179 </button>180 </div>181 </div>182 </header>183 <main className="flex-1 px-4 py-5 lg:px-6 lg:py-6">184 <div className="mx-auto w-full max-w-[1480px]">{children}</div>185 </main>186 </div>187 </div>188 );189}190