SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
7.5 KB · 142 lines tsx
Raw Blame History
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