spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1/**2 * Shared building blocks for entity pages (constellations, operators, countries). Server components only.3 * Kept local to `components/entities/` so shared UI stays untouched.4 */5import { ExternalLink as ExternalIcon } from 'lucide-react';6import Link from 'next/link';7import type { ReactNode } from 'react';8import { cn } from '@/lib/cn';9import { fmtDateTime, fmtInt, titleCase } from '@/lib/format';10import { EVENT_TYPE_LABELS, SITE_NAME, SITE_URL, routes } from '@/lib/site';11import type { EventRow } from '@/lib/types';12import { Unavailable } from '@/components/ui/unavailable';1314// ------------------------------------------------------------------------------------------------------- metadata15export function entityMetadata({ title, description, path, ogImage }: { title: string; description: string; path: string; ogImage?: string }) {16 const url = `${SITE_URL}${path}`;17 const images = ogImage ? [{ url: ogImage, width: 1200, height: 630 }] : undefined;18 return {19 title,20 description,21 alternates: { canonical: path },22 openGraph: { type: 'website' as const, siteName: SITE_NAME, url, title: `${title} | ${SITE_NAME}`, description, images },23 twitter: { card: 'summary_large_image' as const, title: `${title} | ${SITE_NAME}`, description, images: images?.map((i) => i.url) },24 };25}2627// --------------------------------------------------------------------------------------------------- URL helpers28export type Params = Record<string, string | undefined>;2930/** Build an href for the current list page with one param changed (drops `page`). */31export function withParam(base: string, current: Params, key: string, value: string | undefined): string {32 const p = new URLSearchParams();33 for (const [k, v] of Object.entries(current)) if (v && k !== 'page' && k !== key) p.set(k, v);34 if (value) p.set(key, value);35 const s = p.toString();36 return s ? `${base}?${s}` : base;37}3839export function first(v: string | string[] | undefined): string | undefined {40 return Array.isArray(v) ? v[0] : v;41}4243// -------------------------------------------------------------------------------------------------------- chips44export interface ChipOption {45 value: string | undefined;46 label: string;47 count?: number | null;48}4950/** Row of link chips (sort / filter). ≥ 44 px tap targets, horizontally scrollable on small screens. */51export function ChipRow({ label, options, current, base, params, paramKey, className }: { label: string; options: ChipOption[]; current: string | undefined; base: string; params: Params; paramKey: string; className?: string }) {52 return (53 <div className={cn('flex min-w-0 items-center gap-2', className)}>54 <span className="eyebrow shrink-0">{label}</span>55 <ul className="no-scrollbar flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto py-1" role="list">56 {options.map((o) => {57 const active = (o.value ?? '') === (current ?? '');58 return (59 <li key={o.label} className="shrink-0">60 <Link61 href={withParam(base, params, paramKey, o.value)}62 aria-current={active ? 'true' : undefined}63 className={cn('inline-flex h-11 items-center gap-1.5 rounded-md border px-3 text-[13px] transition-colors', active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:bg-plane-2 hover:text-ink')}64 >65 {o.label}66 {o.count != null && <span className="tnum text-[11px] text-ink-3">{fmtInt(o.count)}</span>}67 </Link>68 </li>69 );70 })}71 </ul>72 </div>73 );74}7576// ---------------------------------------------------------------------------------------------------- KPI strip77export interface Kpi {78 label: string;79 value: ReactNode;80 hint?: ReactNode;81 derived?: boolean;82}8384/** Hairline-separated strip of big numbers — not a wall of cards. */85export function KpiStrip({ items, className }: { items: Kpi[]; className?: string }) {86 return (87 <dl className={cn('grid grid-cols-2 gap-x-6 gap-y-5 border-y border-rule py-5 sm:grid-cols-3 lg:grid-cols-6', className)}>88 {items.map((k) => (89 <div key={k.label} className="min-w-0">90 <dt className="eyebrow flex flex-wrap items-center gap-1.5">91 {k.label}92 {k.derived && <Derived />}93 </dt>94 <dd className="tnum mt-1 text-2xl font-semibold tracking-tight md:text-[28px]">{k.value}</dd>95 {k.hint && <dd className="mt-0.5 text-xs text-ink-3">{k.hint}</dd>}96 </div>97 ))}98 </dl>99 );100}101102/** Marks a derived metric and links to the methodology. */103export function Derived({ className }: { className?: string }) {104 return (105 <Link href={routes.methodology()} className={cn('inline-flex items-center rounded-sm border border-accent-2/40 px-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-accent-2 no-underline hover:bg-accent-2-soft', className)} title="Derived metric — see methodology">106 derived107 </Link>108 );109}110111// ---------------------------------------------------------------------------------------------------- hero bits112export function HeroFacts({ items }: { items: { label: string; value: ReactNode }[] }) {113 const rows = items.filter((i) => i.value !== null && i.value !== undefined && i.value !== '—');114 if (!rows.length) return null;115 return (116 <dl className="mt-5 flex flex-wrap gap-x-8 gap-y-3 text-sm">117 {rows.map((i) => (118 <div key={i.label} className="min-w-0">119 <dt className="eyebrow">{i.label}</dt>120 <dd className="mt-0.5 text-ink">{i.value}</dd>121 </div>122 ))}123 </dl>124 );125}126127export function ExternalLink({ href, children }: { href: string; children?: ReactNode }) {128 let host = href;129 try {130 host = new URL(href).host.replace(/^www\./, '');131 } catch {132 /* keep raw */133 }134 return (135 <a href={href} target="_blank" rel="noopener noreferrer" className="link inline-flex items-center gap-1 text-sm">136 {children ?? host} <ExternalIcon className="size-3.5" aria-hidden />137 </a>138 );139}140141/** Small kind/service/stage label (uppercase mono). */142export function Tag({ children, tone = 'ink', className }: { children: ReactNode; tone?: 'ink' | 'accent' | 'accent-2' | 'warn'; className?: string }) {143 const tones = { ink: 'border-rule text-ink-2', accent: 'border-accent/40 text-accent', 'accent-2': 'border-accent-2/40 text-accent-2', warn: 'border-warn/40 text-warn' };144 return <span className={cn('mono inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] uppercase tracking-wide', tones[tone], className)}>{children}</span>;145}146147// ------------------------------------------------------------------------------------------------------ panels148/** Titled block used inside two-column layouts (lighter than <Section>). */149export function Block({ title, eyebrow, action, children, className, id }: { title: ReactNode; eyebrow?: string; action?: { href: string; label: string }; children: ReactNode; className?: string; id?: string }) {150 return (151 <section id={id} className={cn('py-6', className)}>152 <div className="mb-4 flex items-end justify-between gap-4">153 <div>154 {eyebrow && <p className="eyebrow">{eyebrow}</p>}155 <h2 className="text-lg font-semibold tracking-tight md:text-xl">{title}</h2>156 </div>157 {action && (158 <Link href={action.href} className="shrink-0 py-1 text-sm text-accent hover:underline">159 {action.label} →160 </Link>161 )}162 </div>163 {children}164 </section>165 );166}167168/** Planned-but-not-yet-connected data: honest state, never fabricated. */169export function PlannedUnavailable({ what, note }: { what: string; note: string }) {170 return (171 <div>172 <Unavailable what={what} />173 <p className="mt-2 text-xs text-ink-3">{note}</p>174 </div>175 );176}177178// ------------------------------------------------------------------------------------------------------- events179export function EventsList({ events, emptyWhat = 'Events' }: { events: EventRow[] | null | undefined; emptyWhat?: string }) {180 if (!events) return <Unavailable what={emptyWhat} />;181 if (!events.length) return <p className="text-sm text-ink-3">No events recorded for this entity yet.</p>;182 return (183 <ol className="divide-y divide-rule">184 {events.map((e) => (185 <li key={e.id} className="grid gap-1 py-3 sm:grid-cols-[150px_minmax(0,1fr)] sm:gap-4">186 <time dateTime={e.event_time} className="mono text-xs text-ink-3">{fmtDateTime(e.event_time)}</time>187 <div className="min-w-0">188 <div className="flex flex-wrap items-center gap-2">189 <Tag>{EVENT_TYPE_LABELS[e.type] ?? titleCase(e.type)}</Tag>190 <p className="text-sm font-medium text-ink">{e.title}</p>191 </div>192 {e.summary && <p className="mt-1 text-sm text-ink-2">{e.summary}</p>}193 <p className="mt-1 text-xs text-ink-3">194 {e.source_name ? `Source: ${e.source_name}` : 'Source unavailable'} · confidence {Math.round(e.confidence * 100)}%195 </p>196 </div>197 </li>198 ))}199 </ol>200 );201}202203// ------------------------------------------------------------------------------------------------------- tables204export function ScrollTable({ children, className }: { children: ReactNode; className?: string }) {205 return <div className={cn('-mx-1 overflow-x-auto px-1 md:mx-0 md:px-0', className)}>{children}</div>;206}207208export function EmptyRow({ colSpan, children }: { colSpan: number; children: ReactNode }) {209 return (210 <tr>211 <td colSpan={colSpan} className="py-6 text-center text-sm text-ink-3">{children}</td>212 </tr>213 );214}215