1"use client";23import Link from "next/link";4import { usePathname, useRouter } from "next/navigation";5import { useEffect, useState } from "react";6import { api } from "@/lib/api";7import { LiveProvider, useLive } from "@/lib/events";8import { fmtGB } from "@/lib/format";9import { Dot } from "./ui";1011const NAV = [12 { href: "/", label: "Dashboard", icon: "◫" },13 { href: "/models", label: "Models", icon: "▤" },14 { href: "/playground", label: "Playground", icon: "▷" },15 { href: "/downloads", label: "Downloads", icon: "⇩" },16 { href: "/harvester", label: "Harvester", icon: "✦" },17 { href: "/keys", label: "API Keys", icon: "⚿" },18 { href: "/system", label: "System", icon: "◉" },19 { href: "/settings", label: "Settings", icon: "⚙" },20 { href: "/docs", label: "API Docs", icon: "❯" },21];2223type AuthState = { checked: boolean; authenticated: boolean; needsSetup: boolean; email?: string };2425export function Shell({ children }: { children: React.ReactNode }) {26 const path = usePathname();27 const router = useRouter();28 const [auth, setAuth] = useState<AuthState>({ checked: false, authenticated: false, needsSetup: false });29 const isLogin = path === "/login";3031 useEffect(() => {32 let alive = true;33 api.get<{ needs_setup: boolean; authenticated: boolean; principal: { name: string } | null }>("/api/auth/status")34 .then((s) => {35 if (!alive) return;36 setAuth({ checked: true, authenticated: s.authenticated, needsSetup: s.needs_setup, email: s.principal?.name });37 if (!s.authenticated && !isLogin) router.replace(`/login?next=${encodeURIComponent(path)}`);38 if (s.authenticated && isLogin) router.replace("/");39 })40 .catch(() => alive && setAuth({ checked: true, authenticated: false, needsSetup: false }));41 return () => { alive = false; };42 // eslint-disable-next-line react-hooks/exhaustive-deps43 }, [path]);4445 if (isLogin) return <LiveProvider enabled={false}>{children}</LiveProvider>;46 if (!auth.checked || !auth.authenticated) {47 return <div className="min-h-screen grid place-items-center text-ink-3 text-sm">Loading…</div>;48 }49 return (50 <LiveProvider enabled>51 <div className="min-h-screen md:grid md:grid-cols-[232px_1fr]">52 <Sidebar path={path} email={auth.email} />53 <main className="min-w-0 px-4 py-5 md:px-8 md:py-7 pb-24 md:pb-8 max-w-[1400px]">{children}</main>54 <MobileNav path={path} />55 </div>56 </LiveProvider>57 );58}5960function Sidebar({ path, email }: { path: string; email?: string }) {61 const live = useLive();62 const router = useRouter();63 const cur = live.manager?.loaded.find((w) => w.status === "ready");64 const loading = live.manager && Object.values(live.manager.progress).length > 0;65 return (66 <aside className="hidden md:flex flex-col border-r border-border bg-surface/60 sticky top-0 h-screen">67 <div className="px-5 pt-5 pb-4 flex items-center gap-2.5">68 <div className="h-7 w-7 rounded-lg bg-accent grid place-items-center text-white font-bold text-sm">λ</div>69 <div>70 <div className="font-semibold tracking-tight leading-none">LLM API</div>71 <div className="text-[11px] text-ink-3 mt-0.5">private inference</div>72 </div>73 </div>74 <nav className="px-3 flex flex-col gap-0.5">75 {NAV.map((n) => {76 const active = n.href === "/" ? path === "/" : path.startsWith(n.href);77 return (78 <Link key={n.href} href={n.href}79 className={`flex items-center gap-2.5 px-2.5 py-1.5 rounded-lg text-[13px] transition-colors ${active ? "bg-surface-3 text-ink" : "text-ink-2 hover:bg-surface-2 hover:text-ink"}`}>80 <span className="w-4 text-center text-ink-3">{n.icon}</span>{n.label}81 </Link>82 );83 })}84 </nav>85 <div className="mt-auto px-4 pb-4 flex flex-col gap-3">86 <div className="card p-3 text-xs">87 <div className="flex items-center justify-between mb-1.5">88 <span className="label">Current model</span>89 <span className={`flex items-center gap-1 text-[11px] ${live.connected ? "text-good" : "text-ink-3"}`}><Dot className={live.connected ? "bg-good" : "bg-ink-3"} />{live.connected ? "live" : "offline"}</span>90 </div>91 {cur ? (92 <>93 <div className="font-medium truncate" title={cur.model_id}>{cur.model_id}</div>94 <div className="text-ink-3 mt-0.5 num">{fmtGB(cur.measured_gb || cur.estimate_gb)} · {cur.runtime} · ctx {Math.round(cur.context / 1024)}K</div>95 </>96 ) : loading ? (97 <div className="text-accent pulse">Loading model…</div>98 ) : (99 <div className="text-ink-3">No model loaded</div>100 )}101 {live.metrics && (102 <div className="mt-2 text-ink-3 num">RAM {live.metrics.mem_used_gb.toFixed(1)} / {live.metrics.mem_total_gb.toFixed(0)} GB · GPU {live.metrics.gpu_percent ?? "—"}%</div>103 )}104 </div>105 <div className="flex items-center justify-between text-xs text-ink-3 px-1">106 <span className="truncate" title={email}>{email}</span>107 <button className="btn btn-ghost btn-sm" onClick={async () => { await api.post("/api/auth/logout"); router.replace("/login"); }}>Sign out</button>108 </div>109 </div>110 </aside>111 );112}113114function MobileNav({ path }: { path: string }) {115 const items = NAV.slice(0, 5);116 return (117 <nav className="md:hidden fixed bottom-0 inset-x-0 z-40 bg-surface/95 backdrop-blur border-t border-border grid grid-cols-6 pb-[env(safe-area-inset-bottom)]">118 {items.map((n) => {119 const active = n.href === "/" ? path === "/" : path.startsWith(n.href);120 return (121 <Link key={n.href} href={n.href} className={`flex flex-col items-center gap-0.5 py-2 text-[10px] ${active ? "text-ink" : "text-ink-3"}`}>122 <span className="text-base leading-none">{n.icon}</span>{n.label}123 </Link>124 );125 })}126 <MoreMenu path={path} />127 </nav>128 );129}130131function MoreMenu({ path }: { path: string }) {132 const [open, setOpen] = useState(false);133 const rest = NAV.slice(5);134 const active = rest.some((n) => path.startsWith(n.href));135 return (136 <>137 <button onClick={() => setOpen((o) => !o)} className={`flex flex-col items-center gap-0.5 py-2 text-[10px] ${active ? "text-ink" : "text-ink-3"}`}>138 <span className="text-base leading-none">⋯</span>More139 </button>140 {open && (141 <div className="fixed inset-0 z-50 bg-black/50" onClick={() => setOpen(false)}>142 <div className="absolute bottom-0 inset-x-0 card rounded-b-none p-2 pb-[calc(env(safe-area-inset-bottom)+8px)]" onClick={(e) => e.stopPropagation()}>143 {rest.map((n) => (144 <Link key={n.href} href={n.href} onClick={() => setOpen(false)} className="flex items-center gap-3 px-3 py-3 text-sm rounded-lg hover:bg-surface-2">145 <span className="w-5 text-center text-ink-3">{n.icon}</span>{n.label}146 </Link>147 ))}148 <button className="flex items-center gap-3 px-3 py-3 text-sm rounded-lg hover:bg-surface-2 w-full text-left text-ink-2"149 onClick={async () => { await api.post("/api/auth/logout"); location.href = "/login"; }}>150 <span className="w-5 text-center text-ink-3">⏻</span>Sign out151 </button>152 </div>153 </div>154 )}155 </>156 );157}158