/** * Shared building blocks for entity pages (constellations, operators, countries). Server components only. * Kept local to `components/entities/` so shared UI stays untouched. */ import { ExternalLink as ExternalIcon } from 'lucide-react'; import Link from 'next/link'; import type { ReactNode } from 'react'; import { cn } from '@/lib/cn'; import { fmtDateTime, fmtInt, titleCase } from '@/lib/format'; import { EVENT_TYPE_LABELS, SITE_NAME, SITE_URL, routes } from '@/lib/site'; import type { EventRow } from '@/lib/types'; import { Unavailable } from '@/components/ui/unavailable'; // ------------------------------------------------------------------------------------------------------- metadata export function entityMetadata({ title, description, path, ogImage }: { title: string; description: string; path: string; ogImage?: string }) { const url = `${SITE_URL}${path}`; const images = ogImage ? [{ url: ogImage, width: 1200, height: 630 }] : undefined; return { title, description, alternates: { canonical: path }, openGraph: { type: 'website' as const, siteName: SITE_NAME, url, title: `${title} | ${SITE_NAME}`, description, images }, twitter: { card: 'summary_large_image' as const, title: `${title} | ${SITE_NAME}`, description, images: images?.map((i) => i.url) }, }; } // --------------------------------------------------------------------------------------------------- URL helpers export type Params = Record; /** Build an href for the current list page with one param changed (drops `page`). */ export function withParam(base: string, current: Params, key: string, value: string | undefined): string { const p = new URLSearchParams(); for (const [k, v] of Object.entries(current)) if (v && k !== 'page' && k !== key) p.set(k, v); if (value) p.set(key, value); const s = p.toString(); return s ? `${base}?${s}` : base; } export function first(v: string | string[] | undefined): string | undefined { return Array.isArray(v) ? v[0] : v; } // -------------------------------------------------------------------------------------------------------- chips export interface ChipOption { value: string | undefined; label: string; count?: number | null; } /** Row of link chips (sort / filter). ≥ 44 px tap targets, horizontally scrollable on small screens. */ export function ChipRow({ label, options, current, base, params, paramKey, className }: { label: string; options: ChipOption[]; current: string | undefined; base: string; params: Params; paramKey: string; className?: string }) { return (
{label}
); } // ---------------------------------------------------------------------------------------------------- KPI strip export interface Kpi { label: string; value: ReactNode; hint?: ReactNode; derived?: boolean; } /** Hairline-separated strip of big numbers — not a wall of cards. */ export function KpiStrip({ items, className }: { items: Kpi[]; className?: string }) { return (
{items.map((k) => (
{k.label} {k.derived && }
{k.value}
{k.hint &&
{k.hint}
}
))}
); } /** Marks a derived metric and links to the methodology. */ export function Derived({ className }: { className?: string }) { return ( derived ); } // ---------------------------------------------------------------------------------------------------- hero bits export function HeroFacts({ items }: { items: { label: string; value: ReactNode }[] }) { const rows = items.filter((i) => i.value !== null && i.value !== undefined && i.value !== '—'); if (!rows.length) return null; return (
{rows.map((i) => (
{i.label}
{i.value}
))}
); } export function ExternalLink({ href, children }: { href: string; children?: ReactNode }) { let host = href; try { host = new URL(href).host.replace(/^www\./, ''); } catch { /* keep raw */ } return ( {children ?? host} ); } /** Small kind/service/stage label (uppercase mono). */ export function Tag({ children, tone = 'ink', className }: { children: ReactNode; tone?: 'ink' | 'accent' | 'accent-2' | 'warn'; className?: string }) { 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' }; return {children}; } // ------------------------------------------------------------------------------------------------------ panels /** Titled block used inside two-column layouts (lighter than
). */ export function Block({ title, eyebrow, action, children, className, id }: { title: ReactNode; eyebrow?: string; action?: { href: string; label: string }; children: ReactNode; className?: string; id?: string }) { return (
{eyebrow &&

{eyebrow}

}

{title}

{action && ( {action.label} → )}
{children}
); } /** Planned-but-not-yet-connected data: honest state, never fabricated. */ export function PlannedUnavailable({ what, note }: { what: string; note: string }) { return (

{note}

); } // ------------------------------------------------------------------------------------------------------- events export function EventsList({ events, emptyWhat = 'Events' }: { events: EventRow[] | null | undefined; emptyWhat?: string }) { if (!events) return ; if (!events.length) return

No events recorded for this entity yet.

; return (
    {events.map((e) => (
  1. {EVENT_TYPE_LABELS[e.type] ?? titleCase(e.type)}

    {e.title}

    {e.summary &&

    {e.summary}

    }

    {e.source_name ? `Source: ${e.source_name}` : 'Source unavailable'} · confidence {Math.round(e.confidence * 100)}%

  2. ))}
); } // ------------------------------------------------------------------------------------------------------- tables export function ScrollTable({ children, className }: { children: ReactNode; className?: string }) { return
{children}
; } export function EmptyRow({ colSpan, children }: { colSpan: number; children: ReactNode }) { return ( {children} ); }