TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, ChevronRight, Inbox, RefreshCw } from "lucide-react";5import { Button, Sheet, Skeleton } from "@/components/ui";6import { cn } from "@/lib/utils";7import type { ApiClientError } from "@/lib/api";8import type { GameLifecycle } from "@spinza/shared";9import { describeError } from "./format";1011/* ------------------------------------------------------------ page header */1213export function PageHeader({ title, description, actions, eyebrow }: { title: string; description?: React.ReactNode; actions?: React.ReactNode; eyebrow?: string }) {14 return (15 <div className="mb-5 flex flex-wrap items-end justify-between gap-3">16 <div className="min-w-0">17 {eyebrow ? <div className="eyebrow mb-1">{eyebrow}</div> : null}18 <h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1>19 {description ? <p className="mt-1 max-w-2xl text-[13px] text-fg-3">{description}</p> : null}20 </div>21 {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}22 </div>23 );24}2526export function RefreshButton({ onClick, loading, label = "Refresh", size = "sm" }: { onClick: () => void; loading?: boolean; label?: string; size?: "sm" | "md" }) {27 return (28 <Button variant="outline" size={size} onClick={onClick} disabled={loading} aria-label={label} title={label}>29 <RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />30 <span className="hidden sm:inline">{label}</span>31 </Button>32 );33}3435/* ---------------------------------------------------------------- panel */3637export function Panel({ title, description, actions, children, className, padded = true, tone }: { title?: React.ReactNode; description?: React.ReactNode; actions?: React.ReactNode; children: React.ReactNode; className?: string; padded?: boolean; tone?: "danger" | "success" }) {38 return (39 <section className={cn("surface rounded-md", tone === "danger" && "border-danger/30", tone === "success" && "border-success/30", className)}>40 {title || actions ? (41 <header className="flex flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-2.5">42 <div className="min-w-0">43 <h2 className="text-[13px] font-semibold tracking-tight">{title}</h2>44 {description ? <p className="text-[12px] text-fg-3">{description}</p> : null}45 </div>46 {actions ? <div className="flex items-center gap-2">{actions}</div> : null}47 </header>48 ) : null}49 <div className={cn(padded && "p-4")}>{children}</div>50 </section>51 );52}5354/* ------------------------------------------------------------- stat tile */5556export function StatTile({ label, value, sub, tone = "neutral", icon, className, compact }: { label: string; value: React.ReactNode; sub?: React.ReactNode; tone?: "neutral" | "accent" | "success" | "danger" | "info"; icon?: React.ReactNode; className?: string; compact?: boolean }) {57 const valueTone = { neutral: "text-fg", accent: "text-accent-2", success: "text-success", danger: "text-danger", info: "text-info" }[tone];58 return (59 <div className={cn("surface rounded-md px-4 py-3", className)}>60 <div className="flex items-center justify-between gap-2">61 <div className="truncate text-[12px] font-medium text-fg-3">{label}</div>62 {icon ? <span className="text-fg-4">{icon}</span> : null}63 </div>64 <div className={cn("mt-1 font-semibold tracking-tight", compact ? "text-lg" : "text-2xl", valueTone)} style={{ fontVariantNumeric: "proportional-nums" }}>65 {value}66 </div>67 {sub ? <div className="mt-0.5 truncate text-[12px] text-fg-3">{sub}</div> : null}68 </div>69 );70}7172export function StatGrid({ children, cols = 4, className }: { children: React.ReactNode; cols?: 3 | 4 | 5 | 6; className?: string }) {73 const c = { 3: "sm:grid-cols-3", 4: "sm:grid-cols-2 lg:grid-cols-4", 5: "sm:grid-cols-3 xl:grid-cols-5", 6: "sm:grid-cols-3 xl:grid-cols-6" }[cols];74 return <div className={cn("grid grid-cols-2 gap-3", c, className)}>{children}</div>;75}7677/* ------------------------------------------------------------------ pills */7879type PillTone = "neutral" | "accent" | "success" | "danger" | "info" | "warn" | "muted";8081export function Pill({ tone = "neutral", children, className, dot }: { tone?: PillTone; children: React.ReactNode; className?: string; dot?: boolean }) {82 const tones: Record<PillTone, string> = {83 neutral: "bg-surface-2 text-fg-2 border-line",84 muted: "bg-transparent text-fg-3 border-line",85 accent: "bg-accent-soft text-accent-2 border-accent/30",86 success: "bg-success/10 text-success border-success/30",87 danger: "bg-danger/10 text-danger border-danger/30",88 info: "bg-info/10 text-info border-info/30",89 warn: "bg-[#ffb454]/10 text-[#ffc46b] border-[#ffb454]/30",90 };91 return (92 <span className={cn("inline-flex items-center gap-1.5 rounded-full border px-2 py-[2px] text-[11px] font-semibold uppercase tracking-wider whitespace-nowrap", tones[tone], className)}>93 {dot ? <span className="h-1.5 w-1.5 rounded-full bg-current" /> : null}94 {children}95 </span>96 );97}9899export const LIFECYCLE_TONE: Record<GameLifecycle, PillTone> = { draft: "muted", simulation: "info", approved: "accent", staging: "warn", published: "success", disabled: "danger" };100101export function LifecyclePill({ lifecycle }: { lifecycle: GameLifecycle | string }) {102 const tone = (LIFECYCLE_TONE as Record<string, PillTone>)[lifecycle] ?? "neutral";103 return <Pill tone={tone}>{lifecycle}</Pill>;104}105106export function UserStatusPill({ status }: { status: string }) {107 const tone: PillTone = status === "active" ? "success" : status === "suspended" ? "danger" : "muted";108 return (109 <Pill tone={tone} dot>110 {status}111 </Pill>112 );113}114115export function SeverityPill({ severity }: { severity: string }) {116 const tone: PillTone = severity === "high" ? "danger" : severity === "warn" ? "warn" : "muted";117 return <Pill tone={tone}>{severity}</Pill>;118}119120export function PassFail({ pass, label }: { pass: boolean; label?: string }) {121 return <Pill tone={pass ? "success" : "danger"}>{label ?? (pass ? "PASS" : "FAIL")}</Pill>;122}123124/* ------------------------------------------------------------ data table */125126export interface Column<T> {127 key: string;128 header: React.ReactNode;129 render: (row: T) => React.ReactNode;130 align?: "left" | "right" | "center";131 width?: string;132 /** Provide to make the column sortable (client-side). */133 sortValue?: (row: T) => number | string | null;134 className?: string;135 mono?: boolean;136}137138export interface SortState {139 key: string;140 dir: "asc" | "desc";141}142143export function useSort<T>(rows: T[] | null | undefined, columns: Column<T>[], initial?: SortState) {144 const [sort, setSort] = React.useState<SortState | null>(initial ?? null);145 const sorted = React.useMemo(() => {146 if (!rows) return [];147 if (!sort) return rows;148 const col = columns.find((c) => c.key === sort.key);149 if (!col?.sortValue) return rows;150 const sv = col.sortValue;151 return rows152 .map((r, i) => ({ r, i, v: sv(r) }))153 .sort((a, b) => {154 const av = a.v;155 const bv = b.v;156 if (av === null || av === undefined) return 1;157 if (bv === null || bv === undefined) return -1;158 const c = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));159 return (sort.dir === "asc" ? c : -c) || a.i - b.i;160 })161 .map((x) => x.r);162 }, [rows, sort, columns]);163 const toggle = React.useCallback((key: string) => {164 setSort((s) => (s?.key === key ? (s.dir === "desc" ? { key, dir: "asc" } : null) : { key, dir: "desc" }));165 }, []);166 return { sorted, sort, toggle };167}168169export function DataTable<T>({ columns, rows, rowKey, onRowClick, empty, className, sort, onSort, stale, dense, footer, rowClassName }: { columns: Column<T>[]; rows: T[]; rowKey: (row: T) => string; onRowClick?: (row: T) => void; empty?: React.ReactNode; className?: string; sort?: SortState | null; onSort?: (key: string) => void; stale?: boolean; dense?: boolean; footer?: React.ReactNode; rowClassName?: (row: T) => string | undefined }) {170 const alignCls = (a?: Column<T>["align"]) => (a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left");171 return (172 <div className={cn("overflow-x-auto", stale && "opacity-60 transition-opacity", className)}>173 <table className="w-full min-w-[640px] border-collapse text-[13px]">174 <thead>175 <tr className="border-b border-line">176 {columns.map((c) => {177 const sortable = !!(c.sortValue && onSort);178 const active = sort?.key === c.key;179 return (180 <th key={c.key} scope="col" style={{ width: c.width }} className={cn("px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-fg-3 whitespace-nowrap", alignCls(c.align), sortable && "cursor-pointer select-none hover:text-fg-2")} onClick={sortable ? () => onSort?.(c.key) : undefined} aria-sort={active ? (sort?.dir === "asc" ? "ascending" : "descending") : undefined}>181 <span className={cn("inline-flex items-center gap-1", c.align === "right" && "flex-row-reverse")}>182 {c.header}183 {sortable ? active ? sort?.dir === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" /> : <ArrowUpDown className="h-3 w-3 opacity-40" /> : null}184 </span>185 </th>186 );187 })}188 </tr>189 </thead>190 <tbody>191 {rows.length === 0 ? (192 <tr>193 <td colSpan={columns.length} className="px-3 py-10 text-center text-[13px] text-fg-3">194 {empty ?? "Nothing to show."}195 </td>196 </tr>197 ) : (198 rows.map((r) => (199 <tr key={rowKey(r)} onClick={onRowClick ? () => onRowClick(r) : undefined} className={cn("border-b border-line/60 last:border-0", onRowClick && "cursor-pointer hover:bg-surface-2", rowClassName?.(r))}>200 {columns.map((c) => (201 <td key={c.key} className={cn("px-3 align-middle", dense ? "py-1.5" : "py-2", alignCls(c.align), c.mono && "font-mono text-[12px]", (c.align === "right" || c.mono) && "tabular", c.className)}>202 {c.render(r)}203 </td>204 ))}205 </tr>206 ))207 )}208 </tbody>209 {footer ? <tfoot>{footer}</tfoot> : null}210 </table>211 </div>212 );213}214215export function TableSkeleton({ rows = 6, cols = 5 }: { rows?: number; cols?: number }) {216 return (217 <div className="space-y-2 p-3" aria-busy>218 <div className="flex gap-3">219 {Array.from({ length: cols }).map((_, i) => (220 <Skeleton key={i} className="h-3 flex-1" />221 ))}222 </div>223 {Array.from({ length: rows }).map((_, i) => (224 <div key={i} className="flex gap-3">225 {Array.from({ length: cols }).map((_, j) => (226 <Skeleton key={j} className="h-5 flex-1" />227 ))}228 </div>229 ))}230 </div>231 );232}233234export function TileSkeleton({ count = 4, cols = 4 }: { count?: number; cols?: 3 | 4 | 5 | 6 }) {235 return (236 <StatGrid cols={cols}>237 {Array.from({ length: count }).map((_, i) => (238 <div key={i} className="surface rounded-md px-4 py-3">239 <Skeleton className="h-3 w-24" />240 <Skeleton className="mt-2 h-7 w-20" />241 </div>242 ))}243 </StatGrid>244 );245}246247export function ChartSkeleton({ height = 220 }: { height?: number }) {248 return (249 <div style={{ height }} className="w-full">250 <Skeleton className="h-full w-full" />251 </div>252 );253}254255/* --------------------------------------------------------- state blocks */256257export function ErrorState({ error, onRetry, title = "Couldn't load this view" }: { error: ApiClientError | Error | string | null; onRetry?: () => void; title?: string }) {258 const msg = typeof error === "string" ? error : error ? describeError(error) : "";259 return (260 <div className="surface flex flex-col items-center gap-3 rounded-md border-danger/30 px-6 py-10 text-center">261 <div className="grid h-10 w-10 place-items-center rounded-full bg-danger/10 text-danger">262 <AlertTriangle className="h-5 w-5" />263 </div>264 <div>265 <div className="text-[15px] font-semibold">{title}</div>266 {msg ? <div className="mt-1 text-[13px] text-fg-3">{msg}</div> : null}267 </div>268 {onRetry ? (269 <Button variant="outline" size="sm" onClick={onRetry}>270 <RefreshCw className="h-3.5 w-3.5" /> Try again271 </Button>272 ) : null}273 </div>274 );275}276277export function EmptyState({ title, description, action, className }: { title: string; description?: string; action?: React.ReactNode; className?: string }) {278 return (279 <div className={cn("flex flex-col items-center gap-2 px-6 py-10 text-center", className)}>280 <div className="grid h-10 w-10 place-items-center rounded-full bg-surface-2 text-fg-3">281 <Inbox className="h-5 w-5" />282 </div>283 <div className="text-[14px] font-semibold">{title}</div>284 {description ? <p className="max-w-sm text-[13px] text-fg-3">{description}</p> : null}285 {action ? <div className="mt-2">{action}</div> : null}286 </div>287 );288}289290export function InlineError({ children }: { children: React.ReactNode }) {291 return (292 <div className="flex items-start gap-2 rounded-sm border border-danger/30 bg-danger/10 px-3 py-2 text-[13px] text-danger">293 <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />294 <span>{children}</span>295 </div>296 );297}298299/* ------------------------------------------------------------- KV list */300301export function KV({ items, className, cols = 2 }: { items: { label: string; value: React.ReactNode; mono?: boolean }[]; className?: string; cols?: 1 | 2 | 3 }) {302 return (303 <dl className={cn("grid gap-x-6 gap-y-2.5", cols === 1 ? "grid-cols-1" : cols === 3 ? "grid-cols-2 sm:grid-cols-3" : "grid-cols-2", className)}>304 {items.map((it) => (305 <div key={it.label} className="min-w-0">306 <dt className="text-[11px] font-medium uppercase tracking-wider text-fg-4">{it.label}</dt>307 <dd className={cn("mt-0.5 truncate text-[13px] text-fg", it.mono && "font-mono text-[12px]")}>{it.value}</dd>308 </div>309 ))}310 </dl>311 );312}313314/* ------------------------------------------------------- dense controls */315316export const denseControl = "h-9 rounded-sm border border-line-2 bg-bg-1 px-2.5 text-[13px] text-fg placeholder:text-fg-4 focus-ring focus:border-accent/60 disabled:opacity-50";317318export const DenseInput = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(function DenseInput({ className, ...props }, ref) {319 return <input ref={ref} className={cn(denseControl, "w-full", className)} style={{ fontSize: 13 }} {...props} />;320});321322export function DenseSelect({ className, children, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {323 return (324 <div className={cn("relative inline-flex", className)}>325 <select className={cn(denseControl, "w-full appearance-none pr-8")} style={{ fontSize: 13 }} {...props}>326 {children}327 </select>328 <ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-3" />329 </div>330 );331}332333export function DenseTextarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {334 return <textarea className={cn(denseControl, "h-auto w-full py-2 leading-relaxed", className)} style={{ fontSize: 13 }} {...props} />;335}336337export function FieldLabel({ children, hint, className }: { children: React.ReactNode; hint?: React.ReactNode; className?: string }) {338 return (339 <div className={cn("mb-1.5 flex items-baseline justify-between gap-2", className)}>340 <span className="text-[12px] font-medium text-fg-2">{children}</span>341 {hint ? <span className="text-[11px] text-fg-4">{hint}</span> : null}342 </div>343 );344}345346/** Compact switch for table cells and setting rows. */347export function Toggle({ checked, onChange, disabled, label, size = "sm" }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean; label: string; size?: "sm" | "md" }) {348 const dims = size === "sm" ? { track: "h-5 w-9", knob: "h-4 w-4", on: "translate-x-[18px]", off: "translate-x-0.5" } : { track: "h-6 w-11", knob: "h-5 w-5", on: "translate-x-[22px]", off: "translate-x-0.5" };349 return (350 <button type="button" role="switch" aria-checked={checked} aria-label={label} disabled={disabled} onClick={() => onChange(!checked)} className={cn("relative inline-flex shrink-0 items-center rounded-full border transition-colors focus-ring disabled:opacity-50", dims.track, checked ? "border-accent bg-accent" : "border-line-2 bg-surface-3")}>351 <span className={cn("absolute top-1/2 -translate-y-1/2 rounded-full bg-white shadow transition-transform", dims.knob, checked ? dims.on : dims.off)} />352 </button>353 );354}355356export function SegmentedControl<T extends string | number>({ value, onChange, items, className, size = "sm" }: { value: T; onChange: (v: T) => void; items: { value: T; label: string }[]; className?: string; size?: "sm" | "xs" }) {357 return (358 <div className={cn("inline-flex rounded-sm border border-line bg-surface p-0.5", className)} role="radiogroup">359 {items.map((it) => (360 <button key={String(it.value)} type="button" role="radio" aria-checked={value === it.value} onClick={() => onChange(it.value)} className={cn("rounded-[6px] font-semibold transition-colors focus-ring", size === "xs" ? "h-7 px-2 text-[11px]" : "h-8 px-3 text-[12px]", value === it.value ? "bg-surface-3 text-fg shadow" : "text-fg-3 hover:text-fg-2")}>361 {it.label}362 </button>363 ))}364 </div>365 );366}367368/* --------------------------------------------------------- JSON toggle */369370export function JsonToggle({ value, label = "meta" }: { value: unknown; label?: string }) {371 const [open, setOpen] = React.useState(false);372 if (value === null || value === undefined || (typeof value === "object" && Object.keys(value as object).length === 0)) return <span className="text-fg-4">—</span>;373 const keys = typeof value === "object" ? Object.keys(value as object).length : 1;374 return (375 <div className="min-w-0">376 <button type="button" onClick={() => setOpen((o) => !o)} className="inline-flex items-center gap-1 text-[12px] text-fg-3 hover:text-fg focus-ring rounded-xs" aria-expanded={open}>377 <ChevronRight className={cn("h-3 w-3 transition-transform", open && "rotate-90")} />378 {label} <span className="text-fg-4">({keys})</span>379 </button>380 {open ? <pre className="mt-1 max-h-64 max-w-[520px] overflow-auto rounded-xs border border-line bg-bg-1 p-2 font-mono text-[11px] leading-relaxed text-fg-2">{JSON.stringify(value, null, 2)}</pre> : null}381 </div>382 );383}384385/* ------------------------------------------------------ confirm dialog */386387export function ConfirmDialog({ open, onClose, onConfirm, title, description, confirmLabel = "Confirm", danger, loading, children, disabled }: { open: boolean; onClose: () => void; onConfirm: () => void; title: string; description?: React.ReactNode; confirmLabel?: string; danger?: boolean; loading?: boolean; children?: React.ReactNode; disabled?: boolean }) {388 return (389 <Sheet open={open} onClose={onClose} title={title} side="center">390 {description ? <p className="text-[13px] text-fg-2">{description}</p> : null}391 {children ? <div className="mt-4">{children}</div> : null}392 <div className="mt-6 flex justify-end gap-2">393 <Button variant="ghost" size="sm" onClick={onClose} disabled={loading}>394 Cancel395 </Button>396 <Button variant={danger ? "danger" : "primary"} size="sm" onClick={onConfirm} loading={loading} disabled={disabled}>397 {confirmLabel}398 </Button>399 </div>400 </Sheet>401 );402}403404/* ---------------------------------------------------------- misc bits */405406export function Mono({ children, className, title }: { children: React.ReactNode; className?: string; title?: string }) {407 return (408 <span className={cn("font-mono text-[12px] text-fg-2", className)} title={title}>409 {children}410 </span>411 );412}413414export function Delta({ value, digits = 2, invert }: { value: number | null | undefined; digits?: number; invert?: boolean }) {415 if (value === null || value === undefined || !Number.isFinite(value)) return <span className="text-fg-4">—</span>;416 const good = invert ? value <= 0 : value >= 0;417 return (418 <span className={cn("tabular", value === 0 ? "text-fg-3" : good ? "text-success" : "text-danger")}>419 {value > 0 ? "+" : ""}420 {(value * 100).toFixed(digits)}%421 </span>422 );423}424425export function Pagination({ offset, limit, total, onChange }: { offset: number; limit: number; total: number; onChange: (offset: number) => void }) {426 const from = total === 0 ? 0 : offset + 1;427 const to = Math.min(total, offset + limit);428 return (429 <div className="flex items-center justify-between gap-3 border-t border-line px-3 py-2 text-[12px] text-fg-3">430 <span className="tabular">431 {from.toLocaleString("en-US")}–{to.toLocaleString("en-US")} of {total.toLocaleString("en-US")}432 </span>433 <div className="flex gap-1">434 <Button variant="ghost" size="sm" disabled={offset === 0} onClick={() => onChange(Math.max(0, offset - limit))}>435 Previous436 </Button>437 <Button variant="ghost" size="sm" disabled={offset + limit >= total} onClick={() => onChange(offset + limit)}>438 Next439 </Button>440 </div>441 </div>442 );443}444