HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1import type { ReactNode } from 'react';2import { FilterBar } from '@/components/listing/filters';3import { EntityRow } from '@/components/ui/entity';4import { Pagination, withParams } from '@/components/ui/pagination';5import { Container, PageHeader } from '@/components/ui/section';6import { EmptyState, Unavailable } from '@/components/ui/unavailable';7import { api, safe } from '@/lib/api';8import { fmtInt } from '@/lib/format';9import type { EntitySummary, Page } from '@/lib/types';1011const LIMIT = 40;1213/**14 * Generic paged listing for a type with light filters (q, org, sort). `fetch` defaults to /explore/<type>;15 * pass a custom fetcher for typed endpoints (/papers, /hardware). Renders `EntityRow`s — dense, mobile-safe.16 */17export async function GenericListing({ type, basePath, title, eyebrow, lede, searchParams, fetch, sorts = [{ value: 'updated', label: 'Recently updated' }, { value: 'name', label: 'Name' }], extraFields = [], children, rowTrailing, headerAside }: { type: string; basePath: string; title: string; eyebrow: string; lede?: string; searchParams: Record<string, string | undefined>; fetch?: (q: Record<string, string | number | undefined>) => Promise<Page<EntitySummary>>; sorts?: { value: string; label: string }[]; extraFields?: Parameters<typeof FilterBar>[0]['fields']; children?: ReactNode; /** Optional per-row trailing node (e.g. a CompareButton). */ rowTrailing?: (e: EntitySummary) => ReactNode; /** Extra content in the page header aside, next to the total. */ headerAside?: ReactNode }) {18 const current: Record<string, string | undefined> = {};19 for (const k of ['q', 'org', 'sort', 'category', 'kind', 'manufacturer', 'since', 'offset']) if (searchParams[k]) current[k] = searchParams[k];20 const offset = Math.max(0, Number(current.offset) || 0);21 const query = { ...current, limit: LIMIT, offset };22 const page = await safe(fetch ? fetch(query) : api.explore(type, query));23 const href = (patch: Record<string, string | number | undefined | null>) => withParams(basePath, current, patch);24 return (25 <Container>26 <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}>27 <FilterBar action={basePath} className="mt-6" resetHref={basePath} fields={[{ kind: 'text', name: 'q', label: 'Name', value: current.q, placeholder: 'Search by name' }, ...extraFields]} sort={{ value: current.sort ?? sorts[0]?.value, options: sorts }} />28 </PageHeader>29 {children}30 <div className="pb-16">31 {!page ? (32 <Unavailable what={title} />33 ) : page.items.length === 0 ? (34 <EmptyState title={`No ${title.toLowerCase()} match`}>Try another name or remove filters.</EmptyState>35 ) : (36 <>37 <ul className="border-t border-rule">38 {page.items.map((e) => (39 <EntityRow key={e.id} e={e} showType={e.entity_type !== type} trailing={rowTrailing?.(e)} />40 ))}41 </ul>42 <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />43 </>44 )}45 </div>46 </Container>47 );48}49