SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
6.8 KB · 146 lines tsx
Raw Blame History
1import Link from 'next/link';2import type { ReactNode } from 'react';3import { cn } from '@/lib/cn';4import { fmtAgo } from '@/lib/format';5import { routes } from '@/lib/site';6import { FreshnessBadge } from '@/components/ui/badges';7import type { StatsSnapshot } from '@/lib/types';89/** "derived" tag with a link to the methodology page — mandatory next to every computed metric. */10export function Derived({ className, what }: { className?: string; what?: string }) {11  return (12    <Link href={routes.methodology()} className={cn('mono inline-flex items-center gap-1 rounded border border-rule px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-ink-3 hover:border-rule-strong hover:text-accent', className)} title={what ? `${what} — derived by SatelliteIndex, see methodology` : 'Derived by SatelliteIndex, see methodology'}>13      derived14    </Link>15  );16}1718/** Short explanatory note under a chart or table. `tone="warn"` renders a left rule for disclaimers. */19export function Note({ children, tone = 'muted', className }: { children: ReactNode; tone?: 'muted' | 'warn'; className?: string }) {20  return (21    <p className={cn('text-xs leading-relaxed text-ink-3', tone === 'warn' && 'border-l-2 border-warn pl-3 text-ink-2', className)}>22      {children}23    </p>24  );25}2627/** Prominent disclaimer block (debris / reentries pages). Text is shown verbatim from the API. */28export function Disclaimer({ text, label = 'Disclaimer' }: { text: string; label?: string }) {29  return (30    <div className="flex gap-3 border-l-2 border-warn bg-warn-soft/40 py-3 pl-4 pr-4" role="note">31      <div className="min-w-0">32        <p className="eyebrow text-warn">{label}</p>33        <p className="mt-1 text-sm leading-relaxed text-ink">{text}</p>34      </div>35    </div>36  );37}3839/** Responsive grid of `Stat` tiles separated by a hairline on top. */40export function StatGrid({ children, cols = 4, className }: { children: ReactNode; cols?: 3 | 4 | 5; className?: string }) {41  const c = cols === 3 ? 'sm:grid-cols-3' : cols === 5 ? 'sm:grid-cols-3 lg:grid-cols-5' : 'sm:grid-cols-3 lg:grid-cols-4';42  return <div className={cn('grid grid-cols-2 gap-x-6 gap-y-6 border-t border-rule pt-5', c, className)}>{children}</div>;43}4445/** Group title inside a section (e.g. "Catalogue", "Decays"). */46export function GroupTitle({ children, hint }: { children: ReactNode; hint?: ReactNode }) {47  return (48    <div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">49      <h3 className="text-sm font-semibold text-ink-2">{children}</h3>50      {hint && <span className="text-xs text-ink-3">{hint}</span>}51    </div>52  );53}5455/** Chart block: small heading + optional note. No card chrome — hairline composition. */56export function ChartBlock({ title, hint, children, note, derived, className }: { title: ReactNode; hint?: ReactNode; children: ReactNode; note?: ReactNode; derived?: boolean; className?: string }) {57  return (58    <div className={cn('min-w-0', className)}>59      <div className="mb-3 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">60        <h3 className="inline-flex items-center gap-2 text-sm font-semibold text-ink">61          {title}62          {derived && <Derived />}63        </h3>64        {hint && <span className="text-xs text-ink-3">{hint}</span>}65      </div>66      {children}67      {note && <div className="mt-2">{typeof note === 'string' ? <Note>{note}</Note> : note}</div>}68    </div>69  );70}7172/** Two-column layout on desktop, stacked on mobile (DOM order = visual order). */73export function TwoCol({ children, className }: { children: ReactNode; className?: string }) {74  return <div className={cn('grid gap-10 lg:grid-cols-2 lg:gap-12', className)}>{children}</div>;75}7677/** Horizontal chip list (filters / metric selector). Scrolls on mobile without breaking layout. */78export function Chips({ items, className, ariaLabel }: { items: { href: string; label: string; active: boolean; count?: string }[]; className?: string; ariaLabel: string }) {79  return (80    <nav aria-label={ariaLabel} className={cn('no-scrollbar -mx-4 overflow-x-auto px-4 md:mx-0 md:px-0', className)}>81      <ul className="flex w-max gap-2 md:w-auto md:flex-wrap">82        {items.map((it) => (83          <li key={it.href}>84            <Link85              href={it.href}86              aria-current={it.active ? 'page' : undefined}87              className={cn('inline-flex min-h-[44px] items-center gap-1.5 rounded-md border px-3 text-sm transition-colors md:min-h-[36px]', it.active ? 'border-accent/50 bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}88            >89              {it.label}90              {it.count && <span className="mono text-[11px] text-ink-3">{it.count}</span>}91            </Link>92          </li>93        ))}94      </ul>95    </nav>96  );97}9899/** Rank number cell. */100export function Rank({ n, className }: { n: number; className?: string }) {101  return <span className={cn('mono text-xs text-ink-3', className)}>{String(n).padStart(2, '0')}</span>;102}103104/** Inline rank prefix inside the primary cell of ranked tables (works in stacked mode: `.data-table.stack td` forces display:block, so a hidden rank column would reappear on mobile). */105export function InlineRank({ n }: { n: number }) {106  return <Rank n={n} className="mr-2 inline-block w-6 shrink-0 pt-0.5" />;107}108109type Connector = StatsSnapshot['connectors'][number];110111export function connectorFreshness(c: Connector, now = Date.now()): 'fresh' | 'aging' | 'stale' | 'unavailable' | 'not_enabled' {112  if (!c.enabled) return 'not_enabled';113  if (!c.last_success_at) return 'unavailable';114  const t = new Date(c.last_success_at).getTime();115  if (Number.isNaN(t)) return 'unavailable';116  const age = (now - t) / 1000;117  if (age < 3 * c.interval_seconds) return 'fresh';118  if (age < 12 * c.interval_seconds) return 'aging';119  return 'stale';120}121122/** Connector freshness strip — one line per connector. */123export function ConnectorStrip({ connectors }: { connectors: Connector[] }) {124  if (!connectors.length) return null;125  const now = Date.now();126  return (127    <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">128      {connectors.map((c) => {129        const f = connectorFreshness(c, now);130        return (131          <li key={c.name} className="min-w-0 border-t border-rule pt-3">132            <div className="flex items-center justify-between gap-2">133              <span className="mono truncate text-xs text-ink">{c.name}</span>134              <FreshnessBadge status={f} />135            </div>136            <p className="mt-1 text-xs text-ink-3">137              Last success {c.last_success_at ? fmtAgo(c.last_success_at, now) : 'unavailable'} · every {Math.round(c.interval_seconds / 60)} min138              {c.consecutive_failures > 0 && <span className="text-warn"> · {c.consecutive_failures} failure{c.consecutive_failures > 1 ? 's' : ''}</span>}139            </p>140          </li>141        );142      })}143    </ul>144  );145}146