TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import Link from 'next/link';2import { Search } from 'lucide-react';3import type { CategoryNode } from '@rareindex/taxonomy';4import { AssetList } from '@/components/market/asset-list';5import { Pagination } from '@/components/ui/pagination';6import { Card, CardHeader } from '@/components/ui/primitives';7import { attachGuidePrices, exploreAssets, getScopeCounts, categoryScope, type ExploreSort, type HasFilter } from '@/lib/queries/assets';8import { fmtNum, cn } from '@/lib/format';9import { sp1, spEnum, spInt, withParams, type SP } from '@/lib/search-params';1011/**12 * Paginated, filterable browser over canonical assets for a market, set or brand. Lists every13 * catalogued asset — with or without valuation — and states plainly how many are priced yet.14 */15export interface BrowserScope {16 category?: string | null;17 set?: string | null;18 brand?: string | null;19}2021const SORTS: ExploreSort[] = ['relevance', 'riv', 'sales', 'latest_sale', 'change30d', 'newest', 'number', 'name'];22const SORT_LABEL: Record<string, string> = { relevance: 'Most data', riv: 'Valuation', sales: 'Sales', latest_sale: 'Last sale', change30d: '30D change', newest: 'Newest', number: 'Set · number', name: 'Name' };23const HAS: Array<{ id: HasFilter | ''; label: string }> = [24 { id: '', label: 'All' },25 { id: 'sales', label: 'With sales' },26 { id: 'valuation', label: 'With valuation' },27 { id: 'listings', label: 'With listings' },28 { id: 'observations', label: 'With guide price' },29 { id: 'images', label: 'With image' },30];31export const BROWSER_KEYS = ['sort', 'has', 'sub', 'bq', 'view', 'page'];3233export async function AssetBrowser({ scope, sp, basePath, title = 'Assets in this market', subcategories = [], pageSize = 48, anchor = 'assets' }: { scope: BrowserScope; sp: SP; basePath: string; title?: string; subcategories?: CategoryNode[]; pageSize?: number; anchor?: string }) {34 const sort = spEnum<ExploreSort>(sp, 'sort', SORTS, 'relevance');35 const has = (sp1(sp, 'has') as HasFilter | undefined) ?? null;36 const sub = sp1(sp, 'sub') ?? null;37 const q = sp1(sp, 'bq') ?? null;38 const page = spInt(sp, 'page');39 const view = spEnum(sp, 'view', ['table', 'grid'] as const, 'table');40 const categorySlugs = sub && subcategories.some((c) => c.slug === sub) ? categoryScope(sub) : scope.category ? categoryScope(scope.category) : null;41 const filters = { scope: categorySlugs, set: scope.set ?? null, brand: scope.brand ?? null, has: HAS.some((h) => h.id === has) ? has : null, q, sort, page, pageSize };42 const [res, counts] = await Promise.all([exploreAssets(filters), getScopeCounts({ scope: scope.category ? categoryScope(scope.category) : null, set: scope.set ?? null, brand: scope.brand ?? null })]);43 await attachGuidePrices(res.items);44 const params: Record<string, string | undefined> = { sort: sort === 'relevance' ? undefined : sort, has: has ?? undefined, sub: sub ?? undefined, bq: q ?? undefined, view: view === 'table' ? undefined : view };45 const link = (patch: Record<string, string | null | undefined>) => `${withParams(basePath, { ...params, ...patch, page: null })}#${anchor}`;46 const inProgress = counts.assets > 0 && counts.priced < counts.assets;47 const summary = [48 `${fmtNum(counts.assets)} assets`,49 `${fmtNum(counts.priced)} with valuation`,50 counts.withSales ? `${fmtNum(counts.withSales)} with sales` : null,51 counts.withObservations ? `${fmtNum(counts.withObservations)} with guide prices` : null,52 counts.listings ? `${fmtNum(counts.listings)} live listings` : null,53 ].filter(Boolean).join(' · ');54 const columns = sort === 'change30d' ? (['riv', 'change7d', 'change30d', 'latestSale', 'sales'] as const) : (['riv', 'latestSale', 'change30d', 'sales', 'listings'] as const);5556 return (57 <Card as="section" className="overflow-hidden" id={anchor}>58 <CardHeader59 title={title}60 subtitle={61 <span className="num">62 {summary}63 {inProgress ? <span className="ml-1 text-subtle">· valuations in progress</span> : null}64 </span>65 }66 action={67 <form action={basePath} method="get" role="search" className="relative hidden sm:block">68 {Object.entries(params).map(([k, v]) => (v && k !== 'bq' ? <input key={k} type="hidden" name={k} value={v} /> : null))}69 <Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-subtle" />70 <input name="bq" defaultValue={q ?? ''} placeholder="Filter by name, number, set…" className="h-7 w-52 rounded-md border border-border bg-sunken pl-7 pr-2 text-[12px] text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" aria-label="Filter assets" />71 </form>72 }73 />74 <div className="flex flex-col gap-1.5 border-b border-border px-4 py-2 text-[11px]">75 {subcategories.length ? (76 <div className="rail sm:mx-0 sm:flex-wrap sm:px-0 sm:[&>*]:flex-initial items-center gap-1">77 <span className="mr-1 text-subtle">Subcategory</span>78 <Chip href={link({ sub: null })} active={!sub}>79 All80 </Chip>81 {subcategories.map((c) => (82 <Chip key={c.slug} href={link({ sub: c.slug })} active={sub === c.slug}>83 {c.name}84 </Chip>85 ))}86 </div>87 ) : null}88 <div className="rail sm:mx-0 sm:flex-wrap sm:px-0 sm:[&>*]:flex-initial items-center gap-1">89 <span className="mr-1 text-subtle">Show</span>90 {HAS.map((h) => (91 <Chip key={h.id || 'all'} href={link({ has: h.id || null })} active={(has ?? '') === h.id}>92 {h.label}93 </Chip>94 ))}95 </div>96 <div className="rail sm:mx-0 sm:flex-wrap sm:px-0 sm:[&>*]:flex-initial items-center gap-1">97 <span className="mr-1 text-subtle">Sort</span>98 {SORTS.map((s) => (99 <Chip key={s} href={link({ sort: s === 'relevance' ? null : s })} active={sort === s}>100 {SORT_LABEL[s]}101 </Chip>102 ))}103 <span className="ml-auto inline-flex gap-1">104 <Chip href={link({ view: null })} active={view === 'table'}>105 Table106 </Chip>107 <Chip href={link({ view: 'grid' })} active={view === 'grid'}>108 Cards109 </Chip>110 </span>111 </div>112 {q ? (113 <p className="text-muted">114 Filtering by “{q}” ·{' '}115 <Link href={link({ bq: null })} className="underline hover:text-fg">116 clear117 </Link>118 </p>119 ) : null}120 </div>121 {view === 'grid' ? (122 <div className="p-3">123 <AssetList items={res.items} metric="latestSale" forceCards emptyTitle="No assets match these filters" emptyDescription="Clear a filter or pick another sort. Catalog records appear here as soon as connectors ingest them." />124 </div>125 ) : (126 <AssetList items={res.items} columns={[...columns]} rank startRank={(res.page - 1) * res.pageSize + 1} metric="latestSale" emptyTitle="No assets match these filters" emptyDescription="Clear a filter or pick another sort. Catalog records appear here as soon as connectors ingest them." />127 )}128 <div className="px-4 pb-3">129 <Pagination page={res.page} pageSize={res.pageSize} total={res.total} basePath={basePath} params={params} />130 </div>131 </Card>132 );133}134135function Chip({ href, active, children }: { href: string; active: boolean; children: React.ReactNode }) {136 return (137 <Link href={href} scroll={false} className={cn('chip h-7 text-[11px]', active && 'chip-active')}>138 {children}139 </Link>140 );141}142