spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
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