spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
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