spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1import type { SourceKind } from "@dci/core";2import { SOURCE_KINDS } from "@dci/core";3import type { Metadata } from "next";4import { Breadcrumbs } from "@/components/entity/breadcrumbs";5import { ExtLink } from "@/components/entity/kv";6import { SectionNav } from "@/components/entity/section-nav";7import { Badge } from "@/components/ui/badge";8import { Button } from "@/components/ui/button";9import { Page, PageHeader, Section } from "@/components/ui/card";10import { FilterChips, type Chip } from "@/components/ui/filter-chips";11import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";12import { EmptyState, OfflineNotice } from "@/components/ui/states";13import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";14import { api } from "@/lib/api";15import type { SourceWithCounts } from "@/lib/dto";16import { fmtDate, fmtInt } from "@/lib/format";17import { SOURCE_KIND_LABEL } from "@/lib/labels";18import { routes } from "@/lib/routes";19import { pageMetadata } from "@/lib/seo";20import { compareBy, hrefWith, parseSort, sortTh, spGet, type SearchParams } from "@/lib/sort";2122export const revalidate = 120;2324export const metadata: Metadata = pageMetadata({25 title: "Sources",26 description: "Every source the index reads, with its licence, attribution and how much of the graph it backs. Facts only — each value links back to the page it was observed on.",27 path: routes.sources(),28});2930const SORT_KEYS = ["name", "kind", "documents", "provenance", "facilities", "updated"] as const;31type SortKey = (typeof SORT_KEYS)[number];32const KIND_SET = new Set<string>(SOURCE_KINDS);3334function getter(key: SortKey): (s: SourceWithCounts) => number | string | null {35 switch (key) {36 case "name":37 return (s) => s.name;38 case "kind":39 return (s) => SOURCE_KIND_LABEL[s.kind];40 case "documents":41 return (s) => s.documents;42 case "provenance":43 return (s) => s.provenanceRows;44 case "facilities":45 return (s) => s.facilities;46 case "updated":47 return (s) => s.lastDocumentAt ?? s.updatedAt;48 }49}5051const REDIST_TONE: Record<string, "ok" | "info" | "warn" | "neutral"> = { allowed: "ok", attribution: "info", restricted: "warn", unknown: "neutral" };52const REDIST_LABEL: Record<string, string> = { allowed: "Redistribution allowed", attribution: "Attribution required", restricted: "Restricted", unknown: "Re-use terms unknown" };5354/** Redistribution / licence badge from the source registry (drives /download gating). */55function ReuseBadge({ s }: { s: SourceWithCounts }) {56 const r = s.redistribution ?? "unknown";57 return (58 <span className="inline-flex flex-wrap gap-1">59 <Badge tone={REDIST_TONE[r] ?? "neutral"} size="xs" title={`${REDIST_LABEL[r] ?? r}${s.license ? ` — ${s.license}` : ""}`}>60 {r === "attribution" ? "Attribution" : r === "allowed" ? "Allowed" : r === "restricted" ? "Restricted" : "Unknown"}61 </Badge>62 {s.attributionRequired && r !== "attribution" && (63 <Badge tone="outline" size="xs" title="Attribution line must accompany every re-use">64 Attribution65 </Badge>66 )}67 </span>68 );69}7071function truncate(s: string | null | undefined, n: number): string {72 if (!s) return "—";73 return s.length > n ? `${s.slice(0, n - 1)}…` : s;74}7576function SourceTable({ items, sp, grouped, sort }: { items: SourceWithCounts[]; sp: SearchParams; grouped: boolean; sort: ReturnType<typeof parseSort<SortKey>> }) {77 const base = routes.sources();78 const th = (k: SortKey) => sortTh(base, sp, sort, k);79 return (80 <Table dense mobileCollapse={{ total: items.length, label: "sources" }}>81 <THead>82 <tr>83 <Th {...th("name")}>Source</Th>84 {!grouped && <Th {...th("kind")}>Kind</Th>}85 <Th className="hidden md:table-cell">Re-use</Th>86 <Th className="hidden lg:table-cell">License</Th>87 <Th className="hidden xl:table-cell">Attribution</Th>88 <Th numeric {...th("documents")}>89 Documents90 </Th>91 <Th numeric {...th("provenance")}>92 Provenance rows93 </Th>94 <Th numeric {...th("facilities")}>95 Facilities96 </Th>97 <Th className="hidden md:table-cell" {...th("updated")}>98 Last document99 </Th>100 <Th className="hidden sm:table-cell">Link</Th>101 </tr>102 </THead>103 <TBody>104 {items.map((s) => (105 <Tr key={s.id}>106 <Td primary>107 <RowLink href={routes.source(s.id)}>{s.name}</RowLink>108 <div className="figure text-[11.5px] text-ink-3">{s.domain}</div>109 </Td>110 {!grouped && (111 <Td label="Kind">112 <Badge tone="outline" size="xs">113 {SOURCE_KIND_LABEL[s.kind]}114 </Badge>115 </Td>116 )}117 <Td label="Re-use" className="hidden md:table-cell">118 <ReuseBadge s={s} />119 </Td>120 <Td label="License" className="hidden max-w-[240px] text-[12px] text-ink-2 lg:table-cell" title={s.license ?? undefined}>121 {truncate(s.license, 60)}122 </Td>123 <Td label="Attribution" className="hidden max-w-[200px] text-[12px] text-ink-3 xl:table-cell" title={s.attribution ?? undefined}>124 {truncate(s.attribution, 48)}125 </Td>126 <Td label="Documents" numeric lead>127 {fmtInt(s.documents)}128 </Td>129 <Td label="Provenance rows" numeric>130 {fmtInt(s.provenanceRows)}131 </Td>132 <Td label="Facilities" numeric>133 {fmtInt(s.facilities)}134 </Td>135 <Td label="Last document" className="figure hidden text-[12px] text-ink-3 md:table-cell">136 {fmtDate(s.lastDocumentAt)}137 </Td>138 <Td label="Link" className="hidden text-[12px] sm:table-cell">139 {s.url ? <ExtLink href={s.url}>{s.domain}</ExtLink> : <span className="text-ink-4">—</span>}140 </Td>141 </Tr>142 ))}143 </TBody>144 </Table>145 );146}147148export default async function SourcesPage({ searchParams }: { searchParams: Promise<SearchParams> }) {149 const sp = await searchParams;150 const res = await api.sources();151 const all = res.items;152 const q = spGet(sp, "q")?.trim().toLowerCase();153 const kindParam = spGet(sp, "kind");154 const kind = kindParam && KIND_SET.has(kindParam) ? (kindParam as SourceKind) : undefined;155 const sortGiven = Boolean(spGet(sp, "sort"));156 const sort = parseSort<SortKey>(sp, SORT_KEYS, { key: "provenance", order: "desc" });157158 let items = all;159 if (q) items = items.filter((s) => s.name.toLowerCase().includes(q) || s.domain.toLowerCase().includes(q));160 if (kind) items = items.filter((s) => s.kind === kind);161 const cmp = compareBy(getter(sort.key), sort.order);162 items = [...items].sort((a, b) => cmp(a, b) || a.name.localeCompare(b.name));163164 const totals = {165 sources: all.length,166 documents: all.reduce((a, s) => a + s.documents, 0),167 provenance: all.reduce((a, s) => a + s.provenanceRows, 0),168 withData: all.filter((s) => s.provenanceRows > 0).length,169 };170171 const grouped = !sortGiven && !kind;172 const kindsPresent = SOURCE_KINDS.filter((k) => all.some((s) => s.kind === k));173 const groups = grouped174 ? kindsPresent175 .map((k) => ({ kind: k, items: items.filter((s) => s.kind === k) }))176 .filter((g) => g.items.length)177 .sort((a, b) => b.items.reduce((x, s) => x + s.provenanceRows, 0) - a.items.reduce((x, s) => x + s.provenanceRows, 0))178 : [];179180 const chips: Chip[] = [];181 if (q) chips.push({ key: "q", label: "Search", value: q, removeHref: hrefWith(routes.sources(), sp, { q: undefined }) });182 if (kind) chips.push({ key: "kind", label: "Kind", value: SOURCE_KIND_LABEL[kind], removeHref: hrefWith(routes.sources(), sp, { kind: undefined }) });183 if (sortGiven) chips.push({ key: "sort", label: "Sorted by", value: `${sort.key} ${sort.order}`, removeHref: hrefWith(routes.sources(), sp, { sort: undefined, order: undefined }) });184185 return (186 <Page>187 <Breadcrumbs items={[{ name: "Sources", href: routes.sources() }]} className="pt-3" />188 <PageHeader189 eyebrow="Registry"190 title="Sources"191 description="Every source the index reads, with its licence, attribution, re-use terms and how much of the graph it backs. Facts only — each value links back to the page it was observed on."192 className="pt-3"193 aside={194 <Button size="sm" href={`${routes.coverage()}/sources`}>195 Coverage analytics →196 </Button>197 }198 />199 {!res.items.length && !res.total && <OfflineNotice detail="sources unavailable" className="mb-3" />}200201 <MetricRow cols={4}>202 <MetricCell>203 <Metric value={fmtInt(totals.sources)} label="Sources" size="sm" />204 </MetricCell>205 <MetricCell>206 <Metric value={fmtInt(totals.documents)} label="Documents archived" size="sm" />207 </MetricCell>208 <MetricCell>209 <Metric value={fmtInt(totals.provenance)} label="Provenance rows" size="sm" />210 </MetricCell>211 <MetricCell>212 <Metric value={fmtInt(totals.withData)} label="Sources with data" size="sm" note="≥ 1 current provenance row" />213 </MetricCell>214 </MetricRow>215216 <form method="get" action={routes.sources()} className="mt-4 flex flex-wrap items-end gap-2" role="search" aria-label="Filter sources">217 <label className="flex min-w-0 flex-1 flex-col gap-1 sm:max-w-[280px]">218 <span className="label-caps">Search</span>219 <input name="q" defaultValue={q ?? ""} placeholder="Name or domain" className="h-9 min-w-0 rounded-sm border border-hair bg-surface px-2.5 text-[13px] text-ink outline-none focus-visible:ring-2 focus-visible:ring-accent" />220 </label>221 <label className="flex flex-col gap-1">222 <span className="label-caps">Kind</span>223 <select name="kind" defaultValue={kind ?? ""} className="h-9 rounded-sm border border-hair bg-surface px-2 text-[13px] text-ink outline-none focus-visible:ring-2 focus-visible:ring-accent">224 <option value="">All kinds</option>225 {kindsPresent.map((k) => (226 <option key={k} value={k}>227 {SOURCE_KIND_LABEL[k]}228 </option>229 ))}230 </select>231 </label>232 {sortGiven && <input type="hidden" name="sort" value={sort.key} />}233 {sortGiven && <input type="hidden" name="order" value={sort.order} />}234 <Button type="submit" size="md" variant="secondary">235 Apply236 </Button>237 </form>238 <FilterChips chips={chips} resetHref={routes.sources()} className="mt-3" />239240 {!items.length ? (241 <EmptyState className="mt-4" title="No source matches" description={all.length ? "Loosen the search or clear the filters." : "The source registry is not reachable right now."} compact />242 ) : grouped ? (243 <div className="mt-4 flex flex-col gap-6">244 <SectionNav items={groups.map((g) => ({ id: `kind-${g.kind}`, label: SOURCE_KIND_LABEL[g.kind], count: g.items.length }))} />245 {groups.map((g) => (246 <Section key={g.kind} id={`kind-${g.kind}`} className="scroll-mt-28" title={SOURCE_KIND_LABEL[g.kind]} description={`${fmtInt(g.items.length)} ${g.items.length === 1 ? "source" : "sources"} · ${fmtInt(g.items.reduce((a, s) => a + s.provenanceRows, 0))} provenance rows`}>247 <SourceTable items={g.items} sp={sp} grouped sort={sort} />248 </Section>249 ))}250 </div>251 ) : (252 <div className="mt-4">253 <SourceTable items={items} sp={sp} grouped={false} sort={sort} />254 </div>255 )}256 <p className="mt-6 text-[12px] text-ink-4">257 Kinds rank source authority when values conflict (operator, government and filings first; news last) — see the{" "}258 <a href={`${routes.methodology()}#reconciliation`} className="underline decoration-hair-2 hover:text-ink">259 reconciliation rules260 </a>261 . Crawling policy: <a href={routes.bot()} className="underline decoration-hair-2 hover:text-ink">/bot</a>.262 </p>263 </Page>264 );265}266