"use client"; import * as React from "react"; import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, ChevronRight, Inbox, RefreshCw } from "lucide-react"; import { Button, Sheet, Skeleton } from "@/components/ui"; import { cn } from "@/lib/utils"; import type { ApiClientError } from "@/lib/api"; import type { GameLifecycle } from "@spinza/shared"; import { describeError } from "./format"; /* ------------------------------------------------------------ page header */ export function PageHeader({ title, description, actions, eyebrow }: { title: string; description?: React.ReactNode; actions?: React.ReactNode; eyebrow?: string }) { return (
{eyebrow ?
{eyebrow}
: null}

{title}

{description ?

{description}

: null}
{actions ?
{actions}
: null}
); } export function RefreshButton({ onClick, loading, label = "Refresh", size = "sm" }: { onClick: () => void; loading?: boolean; label?: string; size?: "sm" | "md" }) { return ( ); } /* ---------------------------------------------------------------- panel */ export 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" }) { return (
{title || actions ? (

{title}

{description ?

{description}

: null}
{actions ?
{actions}
: null}
) : null}
{children}
); } /* ------------------------------------------------------------- stat tile */ export 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 }) { const valueTone = { neutral: "text-fg", accent: "text-accent-2", success: "text-success", danger: "text-danger", info: "text-info" }[tone]; return (
{label}
{icon ? {icon} : null}
{value}
{sub ?
{sub}
: null}
); } export function StatGrid({ children, cols = 4, className }: { children: React.ReactNode; cols?: 3 | 4 | 5 | 6; className?: string }) { 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]; return
{children}
; } /* ------------------------------------------------------------------ pills */ type PillTone = "neutral" | "accent" | "success" | "danger" | "info" | "warn" | "muted"; export function Pill({ tone = "neutral", children, className, dot }: { tone?: PillTone; children: React.ReactNode; className?: string; dot?: boolean }) { const tones: Record = { neutral: "bg-surface-2 text-fg-2 border-line", muted: "bg-transparent text-fg-3 border-line", accent: "bg-accent-soft text-accent-2 border-accent/30", success: "bg-success/10 text-success border-success/30", danger: "bg-danger/10 text-danger border-danger/30", info: "bg-info/10 text-info border-info/30", warn: "bg-[#ffb454]/10 text-[#ffc46b] border-[#ffb454]/30", }; return ( {dot ? : null} {children} ); } export const LIFECYCLE_TONE: Record = { draft: "muted", simulation: "info", approved: "accent", staging: "warn", published: "success", disabled: "danger" }; export function LifecyclePill({ lifecycle }: { lifecycle: GameLifecycle | string }) { const tone = (LIFECYCLE_TONE as Record)[lifecycle] ?? "neutral"; return {lifecycle}; } export function UserStatusPill({ status }: { status: string }) { const tone: PillTone = status === "active" ? "success" : status === "suspended" ? "danger" : "muted"; return ( {status} ); } export function SeverityPill({ severity }: { severity: string }) { const tone: PillTone = severity === "high" ? "danger" : severity === "warn" ? "warn" : "muted"; return {severity}; } export function PassFail({ pass, label }: { pass: boolean; label?: string }) { return {label ?? (pass ? "PASS" : "FAIL")}; } /* ------------------------------------------------------------ data table */ export interface Column { key: string; header: React.ReactNode; render: (row: T) => React.ReactNode; align?: "left" | "right" | "center"; width?: string; /** Provide to make the column sortable (client-side). */ sortValue?: (row: T) => number | string | null; className?: string; mono?: boolean; } export interface SortState { key: string; dir: "asc" | "desc"; } export function useSort(rows: T[] | null | undefined, columns: Column[], initial?: SortState) { const [sort, setSort] = React.useState(initial ?? null); const sorted = React.useMemo(() => { if (!rows) return []; if (!sort) return rows; const col = columns.find((c) => c.key === sort.key); if (!col?.sortValue) return rows; const sv = col.sortValue; return rows .map((r, i) => ({ r, i, v: sv(r) })) .sort((a, b) => { const av = a.v; const bv = b.v; if (av === null || av === undefined) return 1; if (bv === null || bv === undefined) return -1; const c = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv)); return (sort.dir === "asc" ? c : -c) || a.i - b.i; }) .map((x) => x.r); }, [rows, sort, columns]); const toggle = React.useCallback((key: string) => { setSort((s) => (s?.key === key ? (s.dir === "desc" ? { key, dir: "asc" } : null) : { key, dir: "desc" })); }, []); return { sorted, sort, toggle }; } export function DataTable({ columns, rows, rowKey, onRowClick, empty, className, sort, onSort, stale, dense, footer, rowClassName }: { columns: Column[]; 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 }) { const alignCls = (a?: Column["align"]) => (a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left"); return (
{columns.map((c) => { const sortable = !!(c.sortValue && onSort); const active = sort?.key === c.key; return ( ); })} {rows.length === 0 ? ( ) : ( rows.map((r) => ( onRowClick(r) : undefined} className={cn("border-b border-line/60 last:border-0", onRowClick && "cursor-pointer hover:bg-surface-2", rowClassName?.(r))}> {columns.map((c) => ( ))} )) )} {footer ? {footer} : null}
onSort?.(c.key) : undefined} aria-sort={active ? (sort?.dir === "asc" ? "ascending" : "descending") : undefined}> {c.header} {sortable ? active ? sort?.dir === "asc" ? : : : null}
{empty ?? "Nothing to show."}
{c.render(r)}
); } export function TableSkeleton({ rows = 6, cols = 5 }: { rows?: number; cols?: number }) { return (
{Array.from({ length: cols }).map((_, i) => ( ))}
{Array.from({ length: rows }).map((_, i) => (
{Array.from({ length: cols }).map((_, j) => ( ))}
))}
); } export function TileSkeleton({ count = 4, cols = 4 }: { count?: number; cols?: 3 | 4 | 5 | 6 }) { return ( {Array.from({ length: count }).map((_, i) => (
))}
); } export function ChartSkeleton({ height = 220 }: { height?: number }) { return (
); } /* --------------------------------------------------------- state blocks */ export function ErrorState({ error, onRetry, title = "Couldn't load this view" }: { error: ApiClientError | Error | string | null; onRetry?: () => void; title?: string }) { const msg = typeof error === "string" ? error : error ? describeError(error) : ""; return (
{title}
{msg ?
{msg}
: null}
{onRetry ? ( ) : null}
); } export function EmptyState({ title, description, action, className }: { title: string; description?: string; action?: React.ReactNode; className?: string }) { return (
{title}
{description ?

{description}

: null} {action ?
{action}
: null}
); } export function InlineError({ children }: { children: React.ReactNode }) { return (
{children}
); } /* ------------------------------------------------------------- KV list */ export function KV({ items, className, cols = 2 }: { items: { label: string; value: React.ReactNode; mono?: boolean }[]; className?: string; cols?: 1 | 2 | 3 }) { return (
{items.map((it) => (
{it.label}
{it.value}
))}
); } /* ------------------------------------------------------- dense controls */ export 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"; export const DenseInput = React.forwardRef>(function DenseInput({ className, ...props }, ref) { return ; }); export function DenseSelect({ className, children, ...props }: React.SelectHTMLAttributes) { return (
); } export function DenseTextarea({ className, ...props }: React.TextareaHTMLAttributes) { return