SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
7.3 KB · 146 lines tsx
Raw Blame History
1"use client";23import Link from "next/link";4import { useMemo, useState } from "react";5import { Badge, EmptyState, RelativeTime, SearchInput, SkeletonRows, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";6import { cn } from "@/components/ui/cn";7import { DASH, fmtInt } from "@/lib/format";8import { SOURCE_KIND_LABEL } from "@/lib/labels";9import { routes } from "@/lib/routes";10import type { SourceListItem } from "@/lib/admin-api";11import { ErrorNote, ExtLink, Mono, RefreshButton, Select } from "./primitives";12import { AdminPageHeader } from "./shell";13import { useAdminQuery } from "./use-admin-query";1415type SortKey = "name" | "kind" | "documents" | "provenanceRows" | "facilities" | "lastDocumentAt";1617export function SourcesTable() {18  const q = useAdminQuery<SourceListItem[]>("v1/sources", { refreshMs: 120_000 });19  const [kind, setKind] = useState("");20  const [search, setSearch] = useState("");21  const [sort, setSort] = useState<{ key: SortKey; dir: "asc" | "desc" }>({ key: "provenanceRows", dir: "desc" });22  const list = useMemo(() => q.data ?? [], [q.data]);23  const kinds = useMemo(() => [...new Set(list.map((s) => s.kind))].sort(), [list]);24  const rows = useMemo(() => {25    const s = search.trim().toLowerCase();26    const out = list.filter((x) => (!kind || x.kind === kind) && (!s || x.name.toLowerCase().includes(s) || x.domain.toLowerCase().includes(s) || (x.connectorId ?? "").includes(s) || (x.license ?? "").toLowerCase().includes(s)));27    out.sort((a, b) => {28      const va = a[sort.key];29      const vb = b[sort.key];30      const cmp = typeof va === "number" && typeof vb === "number" ? va - vb : String(va ?? "").localeCompare(String(vb ?? ""));31      return (sort.dir === "asc" ? cmp : -cmp) || a.name.localeCompare(b.name);32    });33    return out;34  }, [list, kind, search, sort]);35  const th = (key: SortKey, label: string, numeric?: boolean) => (36    <Th numeric={numeric} sort={sort.key === key ? sort.dir : null} className="cursor-pointer select-none" onClick={() => setSort((s) => ({ key, dir: s.key === key ? (s.dir === "asc" ? "desc" : "asc") : numeric ? "desc" : "asc" }))}>37      {label}38    </Th>39  );40  const totals = useMemo(() => rows.reduce((a, s) => ({ documents: a.documents + s.documents, provenance: a.provenance + s.provenanceRows, facilities: a.facilities + s.facilities, licensed: a.licensed + (s.license ? 1 : 0) }), { documents: 0, provenance: 0, facilities: 0, licensed: 0 }), [rows]);4142  return (43    <div>44      <AdminPageHeader title="Sources" description={q.data ? `${fmtInt(list.length)} sources · ${fmtInt(totals.licensed)} with a recorded license · ${fmtInt(totals.provenance)} provenance rows` : "Every source behind the index, with license, attribution and what it produced"} aside={<RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} />45      <ErrorNote error={q.error && !q.data ? q.error : null} onRetry={() => void q.refresh()} className="mb-3" />46      <div className="mb-3 flex flex-col gap-2 sm:flex-row">47        <Select value={kind} onChange={(e) => setKind(e.target.value)} className="sm:w-[180px]" aria-label="Kind">48          <option value="">All kinds</option>49          {kinds.map((k) => (50            <option key={k} value={k}>51              {SOURCE_KIND_LABEL[k] ?? k}52            </option>53          ))}54        </Select>55        <SearchInput size="sm" value={search} onChange={setSearch} placeholder="Search name, domain, connector, license" wrapperClassName="flex-1" />56      </div>57      {q.loading && !q.data ? (58        <SkeletonRows rows={10} />59      ) : rows.length === 0 ? (60        <EmptyState compact title="No sources match" />61      ) : (62        <div className="rounded-sm border border-hair bg-surface px-3">63          <Table className="text-[12.5px]">64            <THead>65              <Tr>66                {th("name", "Source")}67                {th("kind", "Kind")}68                <Th>License</Th>69                <Th>Attribution</Th>70                <Th>Docs</Th>71                {th("documents", "Documents", true)}72                {th("provenanceRows", "Provenance", true)}73                {th("facilities", "Facilities", true)}74                {th("lastDocumentAt", "Last document")}75              </Tr>76            </THead>77            <TBody>78              {rows.map((s) => (79                <Tr key={s.id}>80                  <Td primary label="Source" className="max-w-[260px]">81                    <Link href={routes.source(s.id)} target="_blank" className="block truncate font-medium text-ink hover:text-accent hover:underline">82                      {s.name}83                    </Link>84                    <div className="flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3">85                      <span className="truncate">{s.domain}</span>86                      {s.connectorId && (87                        <Link href={`/admin/connectors/${encodeURIComponent(s.connectorId)}`} className="font-mono hover:text-accent">88                          {s.connectorId}89                        </Link>90                      )}91                      {s.robotsAllowed === false && (92                        <Badge size="xs" tone="danger">93                          robots blocked94                        </Badge>95                      )}96                    </div>97                  </Td>98                  <Td label="Kind" muted>99                    {SOURCE_KIND_LABEL[s.kind] ?? s.kind}100                  </Td>101                  <Td label="License" className={cn("max-w-[260px]", !s.license && "text-warn")}>102                    <span className="line-clamp-2" title={s.license ?? undefined}>103                      {s.license ?? "not recorded"}104                    </span>105                  </Td>106                  <Td label="Attribution" className="max-w-[200px]">107                    <span className="line-clamp-2" title={s.attribution ?? undefined}>108                      {s.attribution ?? DASH}109                    </span>110                  </Td>111                  <Td label="Docs">112                    {s.url ? <ExtLink href={s.url}>link</ExtLink> : <span className="text-ink-4">{DASH}</span>}113                    {s.notes && (114                      <span className="ml-1 cursor-help text-ink-4" title={s.notes}>115                        ⓘ116                      </span>117                    )}118                  </Td>119                  <Td label="Documents" numeric>120                    {fmtInt(s.documents)}121                  </Td>122                  <Td label="Provenance" numeric>123                    {fmtInt(s.provenanceRows)}124                  </Td>125                  <Td label="Facilities" numeric>126                    {fmtInt(s.facilities)}127                  </Td>128                  <Td label="Last document" muted>129                    {s.lastDocumentAt ? <RelativeTime iso={s.lastDocumentAt} /> : <Mono>{DASH}</Mono>}130                  </Td>131                </Tr>132              ))}133            </TBody>134          </Table>135          <div className="flex flex-wrap gap-4 border-t border-hair py-2 text-[12px] text-ink-3">136            <span>{fmtInt(rows.length)} sources</span>137            <span className="figure">{fmtInt(totals.documents)} documents</span>138            <span className="figure">{fmtInt(totals.provenance)} provenance rows</span>139            <span className="figure">{fmtInt(totals.facilities)} facilities</span>140          </div>141        </div>142      )}143    </div>144  );145}146