SPB Git forge
1commits 1branches 0releases
189.0 KBsize
maindefault branch
5 h agolast push
Python 54.7% TypeScript 42.5% SQL 1.9%
8.7 KB · 171 lines tsx
Raw Blame History
1"use client";2import { Loader2, X } from "lucide-react";3import { useEffect, useId, type ReactNode } from "react";45export const cx = (...a: (string | false | null | undefined)[]) => a.filter(Boolean).join(" ");67export function Card({ title, subtitle, actions, children, className, id }: { title?: ReactNode; subtitle?: ReactNode; actions?: ReactNode; children: ReactNode; className?: string; id?: string }) {8  return (9    <section id={id} className={cx("rounded-lg border border-line bg-bg-elev print-card", className)}>10      {(title || actions) && (11        <header className="flex items-start justify-between gap-3 border-b border-line px-4 py-3">12          <div>13            {title && <h2 className="text-sm font-semibold text-fg">{title}</h2>}14            {subtitle && <p className="mt-0.5 text-xs text-fg-muted">{subtitle}</p>}15          </div>16          {actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}17        </header>18      )}19      <div className="p-4">{children}</div>20    </section>21  );22}2324export function Tile({ label, value, sub, tone }: { label: string; value: ReactNode; sub?: ReactNode; tone?: "up" | "down" | "warn" | "muted" }) {25  const t = tone === "up" ? "text-up" : tone === "down" ? "text-down" : tone === "warn" ? "text-warn" : tone === "muted" ? "text-fg-muted" : "text-fg";26  return (27    <div className="rounded-lg border border-line bg-bg-elev px-4 py-3">28      <div className="text-xs uppercase tracking-wide text-fg-dim">{label}</div>29      <div className={cx("mt-1 text-xl font-semibold num", t)}>{value}</div>30      {sub && <div className="mt-0.5 text-xs text-fg-muted">{sub}</div>}31    </div>32  );33}3435type BtnProps = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "primary" | "ghost" | "danger" | "outline"; size?: "sm" | "md"; loading?: boolean };36export function Button({ variant = "outline", size = "md", loading, className, children, disabled, ...rest }: BtnProps) {37  const v =38    variant === "primary" ? "bg-accent text-white hover:bg-accent/90 border-transparent" :39    variant === "danger" ? "bg-down text-white hover:bg-down/90 border-transparent" :40    variant === "ghost" ? "border-transparent hover:bg-bg-sub text-fg-muted hover:text-fg" :41    "border-line-strong bg-bg-sub hover:bg-line text-fg";42  const s = size === "sm" ? "h-7 px-2.5 text-xs" : "h-9 px-3.5 text-sm";43  return (44    <button {...rest} disabled={disabled || loading} className={cx("inline-flex items-center justify-center gap-1.5 rounded-md border font-medium transition disabled:cursor-not-allowed disabled:opacity-50", v, s, className)}>45      {loading && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />}46      {children}47    </button>48  );49}5051export function Field({ label, hint, children, htmlFor }: { label: ReactNode; hint?: ReactNode; children: ReactNode; htmlFor?: string }) {52  return (53    <label htmlFor={htmlFor} className="block text-xs">54      <span className="mb-1 block font-medium text-fg-muted">{label}</span>55      {children}56      {hint && <span className="mt-1 block text-[11px] text-fg-dim">{hint}</span>}57    </label>58  );59}6061const inputCls = "h-9 w-full rounded-md border border-line-strong bg-bg px-2.5 text-sm text-fg placeholder:text-fg-dim focus:border-accent";62export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {63  return <input {...props} className={cx(inputCls, props.type === "number" && "num", props.className)} />;64}65export function Select(props: React.SelectHTMLAttributes<HTMLSelectElement>) {66  return <select {...props} className={cx(inputCls, props.className)} />;67}68export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {69  return <textarea {...props} className={cx("w-full rounded-md border border-line-strong bg-bg px-2.5 py-2 font-mono text-xs text-fg focus:border-accent", props.className)} />;70}7172export function Badge({ children, tone = "muted", className }: { children: ReactNode; tone?: "up" | "down" | "warn" | "muted" | "accent" | "live"; className?: string }) {73  const t = {74    up: "bg-up/15 text-up border-up/30",75    down: "bg-down/15 text-down border-down/30",76    warn: "bg-warn/15 text-warn border-warn/30",77    muted: "bg-bg-sub text-fg-muted border-line-strong",78    accent: "bg-accent-soft text-accent border-accent/40",79    live: "bg-live text-white border-live",80  }[tone];81  return <span className={cx("inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[11px] font-medium uppercase tracking-wide", t, className)}>{children}</span>;82}8384export function Dot({ ok, className }: { ok: boolean | null | undefined; className?: string }) {85  return <span aria-hidden className={cx("inline-block h-2 w-2 rounded-full", ok === true ? "bg-up" : ok === false ? "bg-down" : "bg-fg-dim", className)} />;86}8788export function Spinner({ label = "Loading…" }: { label?: string }) {89  return (90    <div className="flex items-center gap-2 py-6 text-sm text-fg-muted" role="status">91      <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> {label}92    </div>93  );94}9596export function Empty({ title, children }: { title: string; children?: ReactNode }) {97  return (98    <div className="rounded-md border border-dashed border-line-strong px-4 py-6 text-center">99      <div className="text-sm font-medium text-fg-muted">{title}</div>100      {children && <div className="mt-1 text-xs text-fg-dim">{children}</div>}101    </div>102  );103}104105export function ErrorBox({ error }: { error: unknown }) {106  const msg = error instanceof Error ? error.message : String(error);107  return <div role="alert" className="rounded-md border border-down/40 bg-down/10 px-3 py-2 text-xs text-down">{msg}</div>;108}109110export function Table({ children, className }: { children: ReactNode; className?: string }) {111  return (112    <div className={cx("overflow-x-auto scroll-thin", className)}>113      <table className="w-full border-collapse text-xs">{children}</table>114    </div>115  );116}117export function Th({ children, className, align = "left", scope = "col" }: { children?: ReactNode; className?: string; align?: "left" | "right" | "center"; scope?: "col" | "row" }) {118  return <th scope={scope} className={cx("border-b border-line px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-fg-dim", align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left", className)}>{children}</th>;119}120export function Td({ children, className, align = "left", num, title }: { children?: ReactNode; className?: string; align?: "left" | "right" | "center"; num?: boolean; title?: string }) {121  return <td title={title} className={cx("border-b border-line/60 px-2 py-1.5 align-middle", align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left", num && "num", className)}>{children}</td>;122}123124export function Dialog({ open, onClose, title, children, footer }: { open: boolean; onClose: () => void; title: string; children: ReactNode; footer?: ReactNode }) {125  const id = useId();126  useEffect(() => {127    if (!open) return;128    const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();129    window.addEventListener("keydown", onKey);130    return () => window.removeEventListener("keydown", onKey);131  }, [open, onClose]);132  if (!open) return null;133  return (134    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>135      <div role="dialog" aria-modal="true" aria-labelledby={id} className="w-full max-w-md rounded-lg border border-line-strong bg-bg-elev shadow-xl" onClick={(e) => e.stopPropagation()}>136        <header className="flex items-center justify-between border-b border-line px-4 py-3">137          <h2 id={id} className="text-sm font-semibold">{title}</h2>138          <button onClick={onClose} aria-label="Close" className="rounded p-1 text-fg-muted hover:bg-bg-sub hover:text-fg"><X className="h-4 w-4" /></button>139        </header>140        <div className="px-4 py-3 text-sm">{children}</div>141        {footer && <footer className="flex justify-end gap-2 border-t border-line px-4 py-3">{footer}</footer>}142      </div>143    </div>144  );145}146147export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: ReactNode; actions?: ReactNode }) {148  return (149    <div className="mb-4 flex flex-wrap items-end justify-between gap-3">150      <div>151        <h1 className="text-lg font-semibold text-fg">{title}</h1>152        {subtitle && <p className="mt-0.5 text-xs text-fg-muted">{subtitle}</p>}153      </div>154      {actions && <div className="flex items-center gap-2">{actions}</div>}155    </div>156  );157}158159export function KV({ items }: { items: [ReactNode, ReactNode][] }) {160  return (161    <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">162      {items.map(([k, v], i) => (163        <div key={i} className="contents">164          <dt className="text-fg-dim">{k}</dt>165          <dd className="num break-all text-fg">{v}</dd>166        </div>167      ))}168    </dl>169  );170}171