SPB Git forge

spb/llm-api

Public
0commits 0branches 0releases
0 Bsize
maindefault branch
—last push
11.3 KB · 234 lines tsx
Raw Blame History
1"use client";23import { useEffect, useState } from "react";4import type { Compat } from "@/lib/types";56export function Pill({ tone = "neutral", children, title }: { tone?: "neutral" | "good" | "warn" | "bad" | "accent" | "violet"; children: React.ReactNode; title?: string }) {7  const map: Record<string, string> = {8    neutral: "bg-surface-3 text-ink-2 border-border-strong",9    good: "bg-[#12301f] text-[#5fd39a] border-[#1f4a31]",10    warn: "bg-[#33260a] text-[#f0b23a] border-[#5a4210]",11    bad: "bg-[#3a1717] text-[#ff8a8a] border-[#5a2b2b]",12    accent: "bg-[#12294a] text-[#7db3ff] border-[#1e3f6e]",13    violet: "bg-[#241f4a] text-[#b9b0ff] border-[#3a3380]",14  };15  return (16    <span className={`pill ${map[tone]}`} title={title}>17      {children}18    </span>19  );20}2122export function StatusPill({ status }: { status: string }) {23  const s = status || "unloaded";24  if (s === "ready") return <Pill tone="good"><Dot className="bg-good" /> Loaded</Pill>;25  if (s === "unloaded") return <Pill tone="neutral"><Dot className="bg-ink-3" /> Unloaded</Pill>;26  if (s === "error") return <Pill tone="bad"><Dot className="bg-bad" /> Error</Pill>;27  if (s === "unloading" || s === "unloading_previous") return <Pill tone="warn"><Dot className="bg-warn pulse" /> Unloading…</Pill>;28  return <Pill tone="accent"><Dot className="bg-accent pulse" /> {s === "queued" ? "Queued" : s === "warming" ? "Warming up" : "Loading…"}</Pill>;29}3031export function CompatPill({ status, reason }: { status: Compat | string | null; reason?: string | null }) {32  const t = reason || undefined;33  switch (status) {34    case "compatible": return <Pill tone="good" title={t}>Compatible</Pill>;35    case "compatible_with_restrictions": return <Pill tone="accent" title={t}>Restricted</Pill>;36    case "experimental": return <Pill tone="violet" title={t}>Experimental</Pill>;37    case "not_recommended": return <Pill tone="warn" title={t}>Not recommended</Pill>;38    case "incompatible": return <Pill tone="bad" title={t}>Incompatible</Pill>;39    default: return <Pill title={t}>Unknown</Pill>;40  }41}4243export function SizePill({ cls }: { cls: string | null }) {44  const tone = cls === "TOO_LARGE" ? "bad" : cls === "XL" ? "warn" : cls === "LARGE" ? "accent" : "neutral";45  return <Pill tone={tone}>{cls || "—"}</Pill>;46}4748export function Dot({ className = "" }: { className?: string }) {49  return <span className={`inline-block h-1.5 w-1.5 rounded-full ${className}`} />;50}5152export function StatTile({ label, value, sub, accent, children }: { label: string; value: React.ReactNode; sub?: React.ReactNode; accent?: string; children?: React.ReactNode }) {53  return (54    <div className="card p-4 flex flex-col gap-1 min-w-0">55      <div className="label">{label}</div>56      <div className="text-[22px] font-semibold tracking-tight num truncate" style={accent ? { color: accent } : undefined}>{value}</div>57      {sub && <div className="text-xs text-ink-3 truncate">{sub}</div>}58      {children}59    </div>60  );61}6263export function Meter({ value, max, tone = "accent", height = 6 }: { value: number; max: number; tone?: "accent" | "good" | "warn" | "bad"; height?: number }) {64  const pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0;65  const color = { accent: "var(--color-accent)", good: "var(--color-good)", warn: "var(--color-warn)", bad: "var(--color-bad)" }[tone];66  return (67    <div className="w-full rounded-full bg-surface-3 overflow-hidden" style={{ height }} role="meter" aria-valuenow={value} aria-valuemin={0} aria-valuemax={max}>68      <div className="h-full rounded-full transition-[width] duration-500" style={{ width: `${pct}%`, background: color }} />69    </div>70  );71}7273export function Progress({ value }: { value: number }) {74  return <Meter value={Math.round(value * 100)} max={100} />;75}7677/** Tiny SVG sparkline (single series, no axes) with a hover tooltip. */78export function Sparkline({ data, color = "var(--series-1)", height = 44, max, unit = "" }: { data: number[]; color?: string; height?: number; max?: number; unit?: string }) {79  const [hover, setHover] = useState<number | null>(null);80  const w = 240;81  if (!data.length) return <div style={{ height }} className="text-xs text-ink-3 flex items-center">no data yet</div>;82  const mx = max ?? Math.max(...data, 1);83  const mn = 0;84  const pts = data.map((v, i) => {85    const x = (i / Math.max(1, data.length - 1)) * w;86    const y = height - 3 - ((v - mn) / (mx - mn || 1)) * (height - 6);87    return [x, y];88  });89  const path = pts.map((p, i) => `${i ? "L" : "M"}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" ");90  const area = `${path} L${w},${height} L0,${height} Z`;91  return (92    <div className="relative w-full" style={{ height }}>93      <svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className="w-full h-full block"94        onMouseMove={(e) => {95          const r = (e.target as SVGElement).closest("svg")!.getBoundingClientRect();96          const i = Math.round(((e.clientX - r.left) / r.width) * (data.length - 1));97          setHover(Math.max(0, Math.min(data.length - 1, i)));98        }}99        onMouseLeave={() => setHover(null)}>100        <path d={area} fill={color} opacity={0.12} />101        <path d={path} fill="none" stroke={color} strokeWidth={2} vectorEffect="non-scaling-stroke" strokeLinejoin="round" />102        {hover != null && (103          <>104            <line x1={pts[hover][0]} x2={pts[hover][0]} y1={0} y2={height} stroke="var(--color-border-strong)" strokeWidth={1} vectorEffect="non-scaling-stroke" />105            <circle cx={pts[hover][0]} cy={pts[hover][1]} r={3.5} fill={color} stroke="var(--color-surface)" strokeWidth={2} vectorEffect="non-scaling-stroke" />106          </>107        )}108      </svg>109      {hover != null && (110        <div className="absolute -top-1 right-0 text-[11px] num text-ink-2 bg-surface-2 border border-border px-1.5 py-0.5 rounded">111          {data[hover].toFixed(unit === "%" ? 0 : 1)}{unit}112        </div>113      )}114    </div>115  );116}117118export function Spinner({ size = 14 }: { size?: number }) {119  return (120    <svg className="spin" width={size} height={size} viewBox="0 0 24 24" fill="none" aria-label="loading">121      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />122      <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />123    </svg>124  );125}126127export function PageHeader({ title, sub, actions }: { title: string; sub?: React.ReactNode; actions?: React.ReactNode }) {128  return (129    <div className="flex flex-wrap items-start justify-between gap-3 mb-5">130      <div>131        <h1 className="text-xl font-semibold tracking-tight">{title}</h1>132        {sub && <div className="text-sm text-ink-3 mt-0.5">{sub}</div>}133      </div>134      {actions && <div className="flex items-center gap-2 flex-wrap">{actions}</div>}135    </div>136  );137}138139export function Empty({ title, sub, action }: { title: string; sub?: string; action?: React.ReactNode }) {140  return (141    <div className="card p-10 text-center">142      <div className="text-sm font-medium">{title}</div>143      {sub && <div className="text-xs text-ink-3 mt-1">{sub}</div>}144      {action && <div className="mt-4">{action}</div>}145    </div>146  );147}148149export function Modal({ open, onClose, title, children, width = 520 }: { open: boolean; onClose: () => void; title: string; children: React.ReactNode; width?: number }) {150  useEffect(() => {151    if (!open) return;152    const h = (e: KeyboardEvent) => e.key === "Escape" && onClose();153    window.addEventListener("keydown", h);154    return () => window.removeEventListener("keydown", h);155  }, [open, onClose]);156  if (!open) return null;157  return (158    <div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 p-0 sm:p-6" onClick={onClose} role="dialog" aria-modal="true">159      <div className="card w-full sm:max-w-[var(--w)] max-h-[90vh] overflow-auto rounded-b-none sm:rounded-b-[12px]" style={{ ["--w" as string]: `${width}px` }} onClick={(e) => e.stopPropagation()}>160        <div className="flex items-center justify-between px-5 py-3 border-b border-border">161          <div className="font-medium">{title}</div>162          <button className="btn btn-ghost btn-sm" onClick={onClose} aria-label="Close">✕</button>163        </div>164        <div className="p-5">{children}</div>165      </div>166    </div>167  );168}169170export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {171  return (172    <label className="inline-flex items-center gap-2 cursor-pointer select-none">173      <span role="switch" aria-checked={checked} tabIndex={0} onKeyDown={(e) => (e.key === " " || e.key === "Enter") && onChange(!checked)}174        onClick={() => onChange(!checked)}175        className={`relative inline-block w-9 h-5 rounded-full transition-colors ${checked ? "bg-accent" : "bg-surface-3 border border-border-strong"}`}>176        <span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${checked ? "translate-x-4" : "translate-x-0.5"}`} />177      </span>178      {label && <span className="text-sm">{label}</span>}179    </label>180  );181}182183export function Tabs<T extends string>({ tabs, value, onChange }: { tabs: { id: T; label: string; count?: number }[]; value: T; onChange: (v: T) => void }) {184  return (185    <div className="flex gap-1 border-b border-border overflow-x-auto">186      {tabs.map((t) => (187        <button key={t.id} onClick={() => onChange(t.id)}188          className={`px-3 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${value === t.id ? "border-accent text-ink" : "border-transparent text-ink-3 hover:text-ink-2"}`}>189          {t.label}{t.count != null && <span className="ml-1.5 text-xs text-ink-3 num">{t.count}</span>}190        </button>191      ))}192    </div>193  );194}195196export function useToast() {197  const [toasts, setToasts] = useState<{ id: number; text: string; tone: "good" | "bad" | "neutral" }[]>([]);198  const push = (text: string, tone: "good" | "bad" | "neutral" = "neutral") => {199    const id = Date.now() + Math.random();200    setToasts((t) => [...t, { id, text, tone }]);201    setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4500);202  };203  const view = (204    <div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">205      {toasts.map((t) => (206        <div key={t.id} className={`card px-4 py-2.5 text-sm shadow-xl ${t.tone === "bad" ? "border-[#5a2b2b]" : t.tone === "good" ? "border-[#1f4a31]" : ""}`}>{t.text}</div>207      ))}208    </div>209  );210  return { push, view };211}212213export function KV({ k, v, mono }: { k: string; v: React.ReactNode; mono?: boolean }) {214  return (215    <div className="flex justify-between gap-4 py-1.5 border-b border-border last:border-0 text-sm">216      <span className="text-ink-3 shrink-0">{k}</span>217      <span className={`text-right truncate ${mono ? "mono text-xs" : ""}`}>{v ?? "—"}</span>218    </div>219  );220}221222export function Code({ children }: { children: string }) {223  const [copied, setCopied] = useState(false);224  return (225    <div className="relative group">226      <pre className="bg-bg border border-border rounded-lg p-3 text-xs overflow-auto mono leading-relaxed">{children}</pre>227      <button className="btn btn-sm btn-ghost absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100"228        onClick={() => { navigator.clipboard.writeText(children); setCopied(true); setTimeout(() => setCopied(false), 1200); }}>229        {copied ? "Copied" : "Copy"}230      </button>231    </div>232  );233}234