SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
6.4 KB · 156 lines tsx
Raw Blame History
1import type { ReactNode } from 'react';2import { CompareButton } from '@/components/compare/compare-button';3import { CompareTrayBar } from '@/components/compare/compare-tray-bar';4import { FilterBar, type FilterField } from '@/components/listing/filters';5import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';6import { Pagination, withParams } from '@/components/ui/pagination';7import { Container, Note, PageHeader } from '@/components/ui/section';8import { EmptyState, Unavailable } from '@/components/ui/unavailable';9import { safe } from '@/lib/api';10import { fmtInt } from '@/lib/format';11import type { EntitySummary, Page } from '@/lib/types';1213/*14  Intelligence listing: the typed table used by /agents /tools /datasets /frameworks. Same URL contract as `TypedListing`15  (q · org · sort · offset, GET FilterBar) but with an optional per-page `enrich(items)` step that fetches extra facts for the16  rows shown (relations, metric history…) and passes them to the column renderers, plus honest empty states:17  no filters → "No X recorded yet — connectors: …"; filters → "No X match these filters".18*/19export type IntelColumn<X> = {20  key: string;21  label: string;22  num?: boolean;23  primary?: boolean;24  wide?: boolean;25  hideStack?: boolean;26  className?: string;27  render: (e: EntitySummary, extra: X | undefined) => ReactNode;28};2930const LIMIT = 40;31const PARAM_KEYS = ['q', 'org', 'sort', 'order', 'category', 'kind', 'since', 'until', 'manufacturer', 'offset'];3233export async function IntelListing<X = never>({34  title,35  eyebrow,36  lede,37  basePath,38  searchParams,39  fetch,40  columns,41  sorts,42  filters = [],43  enrich,44  connectors,45  emptyHint,46  note,47  compare = false,48  headerAside,49  children,50  caption,51}: {52  title: string;53  eyebrow: string;54  lede?: ReactNode;55  basePath: string;56  searchParams: Record<string, string | undefined>;57  fetch: (q: Record<string, string | number | undefined>) => Promise<Page<EntitySummary>>;58  columns: IntelColumn<X>[];59  sorts: { value: string; label: string }[];60  filters?: FilterField[];61  /** Fetch extra facts for the rows on this page (keyed by slug). Failures per row are tolerated (undefined). */62  enrich?: (items: EntitySummary[]) => Promise<Map<string, X>>;63  /** Connector names that feed this type (for the honest empty state). */64  connectors?: string[];65  emptyHint?: ReactNode;66  note?: ReactNode;67  compare?: boolean;68  headerAside?: ReactNode;69  children?: ReactNode;70  caption?: string;71}) {72  const current: Record<string, string | undefined> = {};73  for (const k of PARAM_KEYS) if (searchParams[k]) current[k] = searchParams[k];74  const offset = Math.max(0, Number(current.offset) || 0);75  const sort = current.sort ?? sorts[0]?.value;76  const page = await safe(fetch({ ...current, sort, limit: LIMIT, offset }));77  const extras = page && enrich && page.items.length ? await enrich(page.items).catch(() => new Map<string, X>()) : new Map<string, X>();78  const href = (patch: Record<string, string | number | undefined | null>) => withParams(basePath, current, patch);79  const filtered = Object.keys(current).some((k) => !['sort', 'order', 'offset'].includes(k));80  const cols = columns.length + (compare ? 1 : 0);81  const lower = title.toLowerCase();82  return (83    <Container wide>84      <PageHeader eyebrow={eyebrow} title={title} lede={lede} aside={page || headerAside ? <div className="flex flex-col items-start gap-2 md:items-end">{page && <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} total</p>}{headerAside}</div> : undefined}>85        <FilterBar action={basePath} className="mt-6" resetHref={basePath} fields={[{ kind: 'text', name: 'q', label: 'Name', value: current.q, placeholder: 'Search by name' }, ...filters]} sort={{ value: sort, options: sorts }} />86      </PageHeader>87      {children}88      <div className="pb-16">89        {!page ? (90          <Unavailable what={title} />91        ) : page.items.length === 0 && !offset ? (92          filtered ? (93            <EmptyState title={`No ${lower} match these filters`}>{emptyHint ?? 'Remove a filter or search for another name.'}</EmptyState>94          ) : (95            <EmptyState title={`No ${lower} recorded yet`} className="[&_p]:max-w-2xl [&_p]:mx-auto">96              {emptyHint}97              {connectors && connectors.length > 0 && (98                <span className="mt-1 block">99                  Connectors that will populate this type: <span className="mono text-ink-2">{connectors.join(' · ')}</span>. Nothing is listed until a source has been crawled.100                </span>101              )}102            </EmptyState>103          )104        ) : (105          <>106            <div className="md:overflow-x-auto">107            <DataTable caption={caption ?? title}>108              <thead>109                <tr>110                  {columns.map((c) => (111                    <Th key={c.key} num={c.num}>112                      {c.label}113                    </Th>114                  ))}115                  {compare && <Th className="w-24" aria-label="Compare" />}116                </tr>117              </thead>118              <tbody>119                {page.items.length === 0 && <EmptyRow cols={cols}>No rows on this page.</EmptyRow>}120                {page.items.map((e) => (121                  <tr key={e.id}>122                    {columns.map((c) => (123                      <Td key={c.key} label={c.primary ? undefined : c.label} num={c.num} primary={c.primary} wide={c.wide} hideStack={c.hideStack} className={c.className}>124                        {c.render(e, extras.get(e.slug))}125                      </Td>126                    ))}127                    {compare && (128                      <Td className="text-right">129                        <CompareButton e={e} size="sm" />130                      </Td>131                    )}132                  </tr>133                ))}134              </tbody>135            </DataTable>136            </div>137            <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />138            {note && <Note className="mt-3">{note}</Note>}139          </>140        )}141      </div>142      {compare && <CompareTrayBar />}143    </Container>144  );145}146147export function Dash() {148  return <span className="text-ink-3">—</span>;149}150export function str(v: unknown): string | null {151  return typeof v === 'string' && v.trim() ? v : null;152}153export function list(v: unknown): string[] {154  return Array.isArray(v) ? v.filter((x) => x !== null && x !== undefined).map(String) : [];155}156