SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
6 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
12.0 KB · 266 lines tsx
Raw Blame History
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