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