SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
7.2 KB · 162 lines tsx
Raw Blame History
1import Link from 'next/link';2import type { ReactNode } from 'react';3import { cn } from '@/lib/cn';4import { DASH } from '@/lib/format';56/* Dense admin primitives on the shared tokens. No marketing, no cards. */78const chip = 'inline-flex items-center whitespace-nowrap rounded-[3px] px-1.5 py-[1px] text-[11px] font-medium leading-4 tracking-wide';910const HEALTH: Record<string, string> = {11  ok: 'text-positive bg-positive-soft',12  success: 'text-positive bg-positive-soft',13  done: 'text-positive bg-positive-soft',14  approved: 'text-positive bg-positive-soft',15  extracted: 'text-positive bg-positive-soft',16  active: 'text-positive bg-positive-soft',17  degraded: 'text-warning bg-warning-soft',18  partial: 'text-warning bg-warning-soft',19  skipped: 'text-warning bg-warning-soft',20  queued: 'text-accent bg-accent-soft',21  running: 'text-accent bg-accent-soft',22  pending: 'text-accent bg-accent-soft',23  failing: 'text-danger bg-danger-soft',24  failed: 'text-danger bg-danger-soft',25  error: 'text-danger bg-danger-soft',26  dead: 'text-danger bg-danger-soft',27  rejected: 'text-danger bg-danger-soft',28  schema_error: 'text-danger bg-danger-soft',29  blocked: 'text-danger bg-danger-soft',30  disabled: 'text-ink-3 bg-surface-2',31  unknown: 'text-ink-3 bg-surface-2',32};3334/** Health / status chip: ok → positive · degraded → warning · failing → danger · disabled/unknown → muted. */35export function StatusChip({ value, className }: { value: string | null | undefined; className?: string }) {36  if (!value) return <span className="text-ink-3">{DASH}</span>;37  return <span className={cn(chip, 'mono', HEALTH[value] ?? 'text-ink-2 bg-surface-2', className)}>{value}</span>;38}3940export function KindChip({ value, className }: { value: string; className?: string }) {41  return <span className={cn(chip, 'mono bg-surface-2 text-ink-2', className)}>{value}</span>;42}4344export function Bool({ v }: { v: boolean | null | undefined }) {45  if (v === null || v === undefined) return <span className="text-ink-3">{DASH}</span>;46  return <span className={v ? 'text-positive' : 'text-ink-3'}>{v ? 'yes' : 'no'}</span>;47}4849/** Inline notice from `?notice=&level=` after a server action. */50export function Notice({ notice, level }: { notice?: string; level?: string }) {51  if (!notice) return null;52  const err = level === 'error';53  return (54    <p role="status" className={cn('mb-4 border-l-2 px-3 py-2 text-sm', err ? 'border-danger bg-danger-soft text-danger' : 'border-positive bg-positive-soft text-ink')}>55      {notice}56    </p>57  );58}5960/** Pretty JSON in a scrollable block; never dumps undefined/null as text. */61export function JsonPre({ value, className, maxHeight = '24rem' }: { value: unknown; className?: string; maxHeight?: string }) {62  if (value === null || value === undefined) return <p className="text-xs text-ink-3">none</p>;63  const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);64  return (65    <pre className={cn('scrollbar-thin overflow-auto border border-rule bg-surface p-3 text-[12px] leading-relaxed text-ink', className)} style={{ maxHeight }}>66      <code>{text}</code>67    </pre>68  );69}7071/** Small action button used inside server-action forms. */72export function ActionButton({ children, tone = 'neutral', className, disabled, title }: { children: ReactNode; tone?: 'neutral' | 'accent' | 'danger' | 'positive'; className?: string; disabled?: boolean; title?: string }) {73  return (74    <button75      type="submit"76      disabled={disabled}77      title={title}78      className={cn(79        'inline-flex h-8 items-center justify-center whitespace-nowrap border px-2.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40',80        tone === 'neutral' && 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink',81        tone === 'accent' && 'border-accent text-accent hover:bg-accent-soft',82        tone === 'positive' && 'border-positive text-positive hover:bg-positive-soft',83        tone === 'danger' && 'border-danger text-danger hover:bg-danger-soft',84        className,85      )}86    >87      {children}88    </button>89  );90}9192/** Title line for an admin section. */93export function AdminTitle({ title, count, children, lede }: { title: string; count?: ReactNode; children?: ReactNode; lede?: ReactNode }) {94  return (95    <div className="mb-4 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">96      <div className="min-w-0">97        <h1 className="text-xl font-semibold tracking-tight">98          {title} {count !== undefined && <span className="tnum text-base font-normal text-ink-3">{count}</span>}99        </h1>100        {lede && <p className="mt-1 text-xs text-ink-3">{lede}</p>}101      </div>102      {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}103    </div>104  );105}106107/** Compact GET filter form (selects/text) for admin listings. */108export function AdminFilters({ action, fields, className }: { action: string; fields: ({ kind: 'select'; name: string; label: string; value?: string; options: { value: string; label: string }[]; any?: string } | { kind: 'text'; name: string; label: string; value?: string; placeholder?: string } | { kind: 'hidden'; name: string; value: string })[]; className?: string }) {109  const cls = 'h-9 w-full border border-rule bg-surface px-2 text-xs text-ink focus:border-accent focus:outline-none';110  return (111    <form action={action} method="get" className={cn('grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-6', className)}>112      {fields.map((f) =>113        f.kind === 'hidden' ? (114          <input key={f.name} type="hidden" name={f.name} value={f.value} />115        ) : (116          <label key={f.name} className="block min-w-0">117            <span className="eyebrow block pb-1">{f.label}</span>118            {f.kind === 'select' ? (119              <select name={f.name} defaultValue={f.value ?? ''} className={cls}>120                <option value="">{f.any ?? 'Any'}</option>121                {f.options.map((o) => (122                  <option key={o.value} value={o.value}>123                    {o.label}124                  </option>125                ))}126              </select>127            ) : (128              <input name={f.name} defaultValue={f.value ?? ''} placeholder={f.placeholder} className={cls} />129            )}130          </label>131        ),132      )}133      <div className="flex items-end gap-2">134        <button type="submit" className="h-9 flex-1 bg-ink px-3 text-xs font-medium text-canvas hover:opacity-90">135          Apply136        </button>137        <Link href={action} className="inline-flex h-9 items-center border border-rule px-3 text-xs text-ink-2 hover:text-ink">138          Reset139        </Link>140      </div>141    </form>142  );143}144145export function Mono({ children, className, title }: { children: ReactNode; className?: string; title?: string }) {146  return (147    <span className={cn('mono text-[11.5px] text-ink-2', className)} title={title}>148      {children}149    </span>150  );151}152153/** Truncate long text with the full text as title. */154export function Trunc({ text, max = 80, className }: { text: string | null | undefined; max?: number; className?: string }) {155  if (!text) return <span className="text-ink-3">{DASH}</span>;156  return (157    <span className={className} title={text.length > max ? text : undefined}>158      {text.length > max ? `${text.slice(0, max)}…` : text}159    </span>160  );161}162