SPB Git

spb/findata

Public
8.2 KB · 221 lines tsx
1"use client";2import clsx from "clsx";3import { AlertTriangle, CheckCircle2, CircleDashed, Info, XCircle, type LucideIcon } from "lucide-react";4import Link from "next/link";5import type { ReactNode } from "react";6import { isApiError } from "@/lib/api";78// ---------------------------------------------------------------- page header9export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: ReactNode; actions?: ReactNode }) {10  return (11    <div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">12      <div>13        <h1 className="section-title">{title}</h1>14        {subtitle && <p className="mt-0.5 text-sm text-ink-2">{subtitle}</p>}15      </div>16      {actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}17    </div>18  );19}2021export function Card({ title, children, className, actions, id }: { title?: ReactNode; children: ReactNode; className?: string; actions?: ReactNode; id?: string }) {22  return (23    <section className={clsx("card", className)} id={id}>24      {(title || actions) && (25        <div className="card-title">26          <div>{title}</div>27          {actions && <div className="flex items-center gap-2">{actions}</div>}28        </div>29      )}30      {children}31    </section>32  );33}3435// ---------------------------------------------------------------- status badge36type Tone = "ok" | "warn" | "serious" | "crit" | "neutral" | "accent";3738const TONE: Record<Tone, string> = {39  ok: "bg-ok-soft text-ok-ink",40  warn: "bg-warn-soft text-warn-ink",41  serious: "bg-serious-soft text-serious-ink",42  crit: "bg-crit-soft text-crit-ink",43  neutral: "bg-surface-2 text-ink-2",44  accent: "bg-accent-soft text-accent-ink",45};46const TONE_ICON: Record<Tone, LucideIcon> = { ok: CheckCircle2, warn: AlertTriangle, serious: AlertTriangle, crit: XCircle, neutral: CircleDashed, accent: Info };4748export function toneFor(status: string | null | undefined): Tone {49  switch ((status ?? "").toLowerCase()) {50    case "ok":51    case "active":52    case "succeeded":53    case "ready":54    case "running":55    case "verified":56    case "allowed":57    case "success":58      return status?.toLowerCase() === "running" ? "accent" : "ok";59    case "warning":60    case "planned":61    case "queued":62    case "paused":63    case "pending_configuration":64    case "pending":65    case "stale":66    case "expired":67    case "cancelled":68      return "warn";69    case "degraded":70    case "failed":71    case "error":72    case "dead":73    case "denied":74    case "unsupported":75    case "critical":76      return "crit";77    case "serious":78    case "high":79      return "serious";80    default:81      return "neutral";82  }83}8485export function StatusBadge({ status, label, icon = true, className }: { status: string | null | undefined; label?: string; icon?: boolean; className?: string }) {86  const tone = toneFor(status);87  const Icon = TONE_ICON[tone];88  const text = label ?? (status ?? "unknown").replace(/_/g, " ");89  return (90    <span className={clsx("pill", TONE[tone], className)}>91      {icon && <Icon className="h-3 w-3" aria-hidden />}92      {text}93    </span>94  );95}9697export function SeverityBadge({ severity }: { severity: string | null | undefined }) {98  const s = (severity ?? "").toLowerCase();99  const tone: Tone = s === "error" || s === "critical" || s === "high" ? "crit" : s === "warning" || s === "medium" ? "warn" : s === "info" || s === "low" ? "accent" : "neutral";100  const Icon = TONE_ICON[tone];101  return (102    <span className={clsx("pill", TONE[tone])}>103      <Icon className="h-3 w-3" aria-hidden />104      {s || "—"}105    </span>106  );107}108109// ---------------------------------------------------------------- KPI tile110export function KpiTile({ label, value, sub, tone, href, className }: { label: string; value: ReactNode; sub?: ReactNode; tone?: Tone; href?: string; className?: string }) {111  const body = (112    <>113      <div className="text-xs text-ink-2">{label}</div>114      <div className={clsx("mt-1 text-2xl font-semibold tracking-tight", tone === "crit" ? "text-crit-ink" : tone === "warn" ? "text-warn-ink" : tone === "ok" ? "text-ok-ink" : "text-ink-1")}>115        {value}116      </div>117      {sub && <div className="mt-0.5 text-xs text-ink-3">{sub}</div>}118    </>119  );120  const cls = clsx("card min-w-0", href && "transition hover:border-accent/60", className);121  return href ? (122    <Link href={href} className={cls}>123      {body}124    </Link>125  ) : (126    <div className={cls}>{body}</div>127  );128}129130// ---------------------------------------------------------------- misc131export function Banner({ tone = "accent", children, className }: { tone?: Tone; children: ReactNode; className?: string }) {132  const Icon = TONE_ICON[tone];133  const border = { ok: "border-ok/50", warn: "border-warn/60", serious: "border-serious/60", crit: "border-crit/60", neutral: "border-border", accent: "border-accent/40" }[tone];134  return (135    <div className={clsx("banner flex items-start gap-2", TONE[tone].split(" ")[0], border, className)}>136      <Icon className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />137      <div className="min-w-0 flex-1">{children}</div>138    </div>139  );140}141142export function ErrorState({ error, retry }: { error: unknown; retry?: () => void }) {143  const msg = isApiError(error) ? `${error.message} (${error.code}, HTTP ${error.status})` : error instanceof Error ? error.message : "Unknown error";144  const rid = isApiError(error) ? error.request_id : null;145  const forbidden = isApiError(error) && error.status === 403;146  return (147    <div className="banner flex items-start gap-2 border-crit/60 bg-crit-soft text-crit-ink">148      <XCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />149      <div className="min-w-0 flex-1">150        <div className="font-medium">{forbidden ? "Not allowed" : "Could not load"}</div>151        <div className="text-xs break-words">{msg}</div>152        {rid && <div className="mt-0.5 font-mono text-[11px] opacity-80">request_id {rid}</div>}153      </div>154      {retry && (155        <button className="btn btn-sm" onClick={retry}>156          Retry157        </button>158      )}159    </div>160  );161}162163export function Empty({ children = "Nothing here yet." }: { children?: ReactNode }) {164  return <div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-ink-3">{children}</div>;165}166167export function Skeleton({ rows = 4, className }: { rows?: number; className?: string }) {168  return (169    <div className={clsx("flex flex-col gap-2", className)} aria-busy="true" aria-label="Loading">170      {Array.from({ length: rows }).map((_, i) => (171        <div key={i} className="skeleton h-4" style={{ width: `${90 - (i % 3) * 15}%` }} />172      ))}173    </div>174  );175}176177export function Field({ label, children, hint, className }: { label: string; children: ReactNode; hint?: ReactNode; className?: string }) {178  return (179    <label className={clsx("block min-w-0", className)}>180      <span className="label">{label}</span>181      {children}182      {hint && <span className="mt-0.5 block text-[11px] text-ink-3">{hint}</span>}183    </label>184  );185}186187export function KV({ items, className }: { items: [ReactNode, ReactNode][]; className?: string }) {188  return (189    <dl className={clsx("grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm", className)}>190      {items.map(([k, v], i) => (191        <div key={i} className="contents">192          <dt className="text-ink-2 whitespace-nowrap">{k}</dt>193          <dd className="min-w-0 break-words text-ink-1">{v ?? "—"}</dd>194        </div>195      ))}196    </dl>197  );198}199200export function Mono({ children, className }: { children: ReactNode; className?: string }) {201  return <span className={clsx("font-mono text-[12.5px]", className)}>{children}</span>;202}203204export function Tabs<T extends string>({ value, onChange, items }: { value: T; onChange: (v: T) => void; items: { value: T; label: string }[] }) {205  return (206    <div role="tablist" className="inline-flex rounded-md border border-border bg-surface-2 p-0.5">207      {items.map((it) => (208        <button209          key={it.value}210          role="tab"211          aria-selected={value === it.value}212          className={clsx("rounded px-2.5 py-1 text-xs font-medium", value === it.value ? "bg-surface-1 text-ink-1 shadow-sm" : "text-ink-2 hover:text-ink-1")}213          onClick={() => onChange(it.value)}214        >215          {it.label}216        </button>217      ))}218    </div>219  );220}221
Files · 91e6b48ac4edd7ad417593b39de65ed1b2260997
Menu

Theme