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%
21.3 KB · 444 lines tsx
Raw Blame History
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