SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
4.9 KB · 106 lines tsx
Raw Blame History
1'use client';2import { KeyRound, LogOut, RefreshCw } from 'lucide-react';3import Link from 'next/link';4import { usePathname } from 'next/navigation';5import { type ReactNode, useState } from 'react';6import { Container } from '@/components/ui/section';7import { ADMIN_MODULES, setAdminToken, useAdminToken } from '@/lib/admin';8import { cn } from '@/lib/cn';910/** Token gate + module navigation. The token never leaves localStorage except as the `X-CA-Admin-Token` header. */11export function AdminShell({ children, title, onRefresh, refreshing }: { children: ReactNode; title: string; onRefresh?: () => void; refreshing?: boolean }) {12  const token = useAdminToken();13  const pathname = usePathname();14  const [draft, setDraft] = useState('');15  if (token === undefined) return <Container className="py-10 text-sm text-ink-3">Loading…</Container>;16  if (!token)17    return (18      <Container className="py-12 md:py-20">19        <p className="eyebrow">Admin</p>20        <h1 className="display mt-2 text-3xl">Operator console</h1>21        <p className="mt-3 max-w-lg text-sm text-ink-2">Enter the admin token (`CA_ADMIN_TOKEN`). It is stored in this browser only and sent as <span className="mono">X-CA-Admin-Token</span> to same-origin API calls.</p>22        <form23          className="mt-5 flex max-w-md gap-2"24          onSubmit={(e) => {25            e.preventDefault();26            if (draft.trim()) setAdminToken(draft.trim());27          }}28        >29          <label htmlFor="admin-token" className="sr-only">30            Admin token31          </label>32          <input id="admin-token" type="password" value={draft} onChange={(e) => setDraft(e.target.value)} className="field flex-1" placeholder="admin token" autoComplete="off" data-admin-token />33          <button type="submit" className="btn btn-primary">34            <KeyRound className="size-4" aria-hidden /> Enter35          </button>36        </form>37      </Container>38    );39  return (40    <Container wide className="py-4 md:py-6">41      <div className="flex flex-col gap-4 lg:flex-row">42        <nav aria-label="Admin modules" className="no-scrollbar -mx-4 flex gap-1 overflow-x-auto px-4 lg:mx-0 lg:w-48 lg:shrink-0 lg:flex-col lg:px-0">43          {ADMIN_MODULES.map((m) => {44            const href = `/admin/${m.id}`;45            const on = pathname === href || (m.id === 'overview' && pathname === '/admin');46            return (47              <Link key={m.id} href={href} className={cn('flex h-9 shrink-0 items-center rounded-sm px-2.5 text-[13px] whitespace-nowrap', on ? 'bg-surface-2 font-medium text-ink' : 'text-ink-2 hover:text-ink')} title={m.hint}>48                {m.label}49              </Link>50            );51          })}52          <button type="button" onClick={() => setAdminToken(null)} className="mt-auto flex h-9 shrink-0 items-center gap-1.5 rounded-sm px-2.5 text-[13px] text-ink-3 hover:text-danger">53            <LogOut className="size-3.5" aria-hidden /> Sign out54          </button>55        </nav>56        <div className="min-w-0 flex-1">57          <div className="mb-3 flex items-center justify-between gap-3">58            <h1 className="text-xl font-semibold tracking-tight">{title}</h1>59            {onRefresh && (60              <button type="button" onClick={onRefresh} className="btn btn-sm" disabled={refreshing}>61                <RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} aria-hidden /> Refresh62              </button>63            )}64          </div>65          {children}66        </div>67      </div>68    </Container>69  );70}7172export function AdminError({ error }: { error: string | null }) {73  if (!error) return null;74  return (75    <p className="mb-3 border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger" role="alert">76      {error}77      {/401|403/.test(error) ? ' — the token was rejected. Sign out and enter it again.' : ''}78    </p>79  );80}8182export function AdminTable({ head, children, className }: { head: ReactNode; children: ReactNode; className?: string }) {83  return (84    <div className={cn('table-scroll', className)}>85      <table className="data-table compact">86        <thead>{head}</thead>87        <tbody>{children}</tbody>88      </table>89    </div>90  );91}9293export function KpiRow({ items }: { items: { label: string; value: ReactNode; hint?: ReactNode; tone?: 'positive' | 'warning' | 'danger' }[] }) {94  return (95    <div className="grid grid-cols-2 gap-x-6 border-y border-rule sm:grid-cols-3 lg:grid-cols-6 [&>*]:border-b [&>*]:border-rule lg:[&>*]:border-b-0">96      {items.map((i) => (97        <div key={i.label} className="min-w-0 py-3">98          <p className="eyebrow">{i.label}</p>99          <p className={cn('tnum mt-1 text-[22px] font-semibold leading-none tracking-tight', i.tone === 'positive' && 'text-positive', i.tone === 'warning' && 'text-warning', i.tone === 'danger' && 'text-danger')}>{i.value}</p>100          {i.hint && <p className="mt-1 text-[11px] text-ink-3">{i.hint}</p>}101        </div>102      ))}103    </div>104  );105}106