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%
9.5 KB · 215 lines tsx
Raw Blame History
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