SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
5.4 KB · 107 lines tsx
Raw Blame History
1import Link from 'next/link';2import type { ReactNode } from 'react';3import { cn } from '@/lib/format';45export const ADMIN_NAV = [6  { href: '/admin', label: 'Overview' },7  { href: '/admin/connectors', label: 'Connectors' },8  { href: '/admin/coverage', label: 'Coverage' },9  { href: '/admin/data-quality', label: 'Data quality' },10  { href: '/admin/costs', label: 'Costs' },11  { href: '/admin/taxonomy', label: 'Taxonomy' },12  { href: '/admin/audit', label: 'Audit log' },13  { href: '/admin/events', label: 'Events' },14];1516export function AdminShell({ children, title, subtitle, actions, current }: { children: ReactNode; title: string; subtitle?: ReactNode; actions?: ReactNode; current: string }) {17  return (18    <div className="grid gap-6 lg:grid-cols-[180px_1fr]">19      <aside className="lg:sticky lg:top-20 lg:self-start">20        <p className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-subtle">Admin</p>21        <nav className="flex gap-1 overflow-x-auto scrollbar-none lg:flex-col">22          {ADMIN_NAV.map((n) => (23            <Link key={n.href} href={n.href} className={cn('whitespace-nowrap rounded-md px-2.5 py-1.5 text-[13px]', current === n.href ? 'bg-inset font-medium text-fg' : 'text-muted hover:bg-inset hover:text-fg')}>24              {n.label}25            </Link>26          ))}27        </nav>28      </aside>29      <div className="min-w-0">30        <header className="mb-4 flex flex-wrap items-end justify-between gap-3">31          <div>32            <h1 className="text-xl font-semibold tracking-tight">{title}</h1>33            {subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null}34          </div>35          {actions}36        </header>37        {children}38      </div>39    </div>40  );41}4243export function StatusPill({ status }: { status: string | null | undefined }) {44  const s = status ?? 'unknown';45  const tone: Record<string, string> = { maintenance: 'bg-index-bg text-index', completed: 'bg-gain-bg text-gain', healthy: 'bg-gain-bg text-gain', active: 'bg-gain-bg text-gain', success: 'bg-gain-bg text-gain', valid: 'bg-gain-bg text-gain', degraded: 'bg-alert-bg text-alert', partial: 'bg-alert-bg text-alert', paused: 'bg-alert-bg text-alert', flagged: 'bg-alert-bg text-alert', pending: 'bg-alert-bg text-alert', running: 'bg-index-bg text-index', failing: 'bg-loss-bg text-loss', failed: 'bg-loss-bg text-loss', excluded: 'bg-loss-bg text-loss', disabled: 'bg-inset text-muted', unknown: 'bg-inset text-muted' };46  return <span className={cn('inline-flex rounded-sm px-1.5 py-0.5 text-[11px] font-medium', tone[s] ?? 'bg-inset text-muted')}>{s}</span>;47}4849export function ActionButton({ label, tone = 'neutral', small = true }: { label: string; tone?: 'neutral' | 'danger' | 'primary'; small?: boolean }) {50  return (51    <button type="submit" className={cn('rounded-md border font-medium', small ? 'px-2 py-0.5 text-[11px]' : 'px-3 py-1.5 text-xs', tone === 'danger' ? 'border-loss/40 text-loss hover:bg-loss-bg' : tone === 'primary' ? 'border-accent bg-accent text-accent-fg' : 'border-border hover:bg-inset')}>52      {label}53    </button>54  );55}5657export function Kpi({ label, value, sub, tone }: { label: string; value: ReactNode; sub?: ReactNode; tone?: 'gain' | 'loss' | 'alert' }) {58  return (59    <div className="card px-3 py-2.5">60      <p className="text-[10px] font-semibold uppercase tracking-wider text-subtle">{label}</p>61      <p className={cn('num mt-0.5 text-lg font-semibold', tone === 'gain' && 'text-gain', tone === 'loss' && 'text-loss', tone === 'alert' && 'text-alert')}>{value}</p>62      {sub ? <p className="text-[11px] text-muted">{sub}</p> : null}63    </div>64  );65}6667/** Single-series bar chart (one hue, hover tooltips via <title>, no legend needed). */68export function Bars({ points, format, className }: { points: Array<{ label: string; value: number }>; format?: (v: number) => string; className?: string }) {69  if (!points.length) return <p className="text-xs text-subtle">No data in this window.</p>;70  const max = Math.max(...points.map((p) => p.value), 0) || 1;71  const w = 600;72  const h = 120;73  const gap = 2;74  const bw = Math.max(2, (w - gap * points.length) / points.length);75  const fmt = format ?? ((v: number) => String(Math.round(v * 100) / 100));76  return (77    <svg viewBox={`0 0 ${w} ${h + 16}`} className={cn('h-auto w-full', className)} role="img" aria-label="Daily values">78      <line x1={0} x2={w} y1={h} y2={h} stroke="var(--ri-border)" />79      {points.map((p, i) => {80        const bh = Math.max(p.value > 0 ? 1 : 0, (p.value / max) * (h - 8));81        return (82          <g key={p.label}>83            <rect x={i * (bw + gap)} y={h - bh} width={bw} height={bh} rx={bw > 6 ? 2 : 0} fill="var(--ri-index)" opacity={0.85}>84              <title>{`${p.label}: ${fmt(p.value)}`}</title>85            </rect>86            {points.length <= 31 && (i === 0 || i === points.length - 1) ? (87              <text x={i * (bw + gap) + bw / 2} y={h + 12} textAnchor="middle" fontSize={9} fill="var(--ri-fg-subtle)">{p.label.slice(5)}</text>88            ) : null}89          </g>90        );91      })}92    </svg>93  );94}9596export function fmtTs(v: unknown): string {97  if (!v) return '—';98  const d = v instanceof Date ? v : new Date(String(v));99  if (Number.isNaN(d.getTime())) return String(v);100  return d.toISOString().replace('T', ' ').slice(0, 16) + 'Z';101}102103export function n(v: unknown, digits = 0): string {104  const x = Number(v);105  return Number.isFinite(x) ? x.toLocaleString('en-US', { maximumFractionDigits: digits }) : '—';106}107