SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
8.1 KB · 190 lines tsx
Raw Blame History
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