TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import Link from 'next/link';2import type { AssetCard as AssetCardData } from '@/lib/queries/assets';3import { fmtMoney, fmtNum, fmtRelative, confidenceLabel, cn } from '@/lib/format';4import { catName } from '@/lib/taxonomy';5import { Delta, Table, th, td, tdNum, EmptyState, VsRiv } from '@/components/ui/primitives';6import { Thumb } from './bits';7import { PriceLine, TileImage } from './asset-tile';89export type AssetColumn = 'riv' | 'change1d' | 'change7d' | 'change30d' | 'change1y' | 'latestSale' | 'sales' | 'sales30d' | 'listings' | 'minAsk' | 'liquidity' | 'rarity' | 'trending' | 'opportunity' | 'watchers' | 'updated';1011const DEFAULT_COLUMNS: AssetColumn[] = ['riv', 'change7d', 'change30d', 'latestSale', 'sales', 'listings', 'liquidity'];1213const HEAD: Record<AssetColumn, string> = {14 riv: 'RIV (USD)',15 change1d: '1D',16 change7d: '7D',17 change30d: '30D',18 change1y: '1Y',19 latestSale: 'Last sale',20 sales: 'Sales',21 sales30d: 'Sales 30D',22 listings: 'Listings',23 minAsk: 'Low ask',24 liquidity: 'Liquidity',25 rarity: 'Rarity',26 trending: 'Trending',27 opportunity: 'Value opp.',28 watchers: 'Watchers',29 updated: 'Updated',30};3132function cell(a: AssetCardData, c: AssetColumn) {33 switch (c) {34 case 'riv':35 return a.rivUsd === null ? (36 a.guideUsd != null ? (37 <span className="inline-flex flex-col items-end leading-tight" title={`Price-guide observation (${a.guideSource ?? 'guide'} · ${a.guideKind ?? ''} · ${a.guideDate ?? ''}) — not a transaction and not a RareIndex valuation`}>38 <span className="text-muted">{fmtMoney(a.guideUsd)}</span>39 <span className="text-[10px] text-subtle">guide · {a.guideSource ?? a.guideKind ?? 'observed'}</span>40 </span>41 ) : (42 <span className="text-subtle" title="Insufficient evidence for a valuation">—</span>43 )44 ) : (45 <span className="inline-flex flex-col items-end leading-tight">46 <span className="font-medium">{fmtMoney(a.rivUsd)}</span>47 <span className="text-[10px] text-subtle">48 {confidenceLabel(a.rivConfidence)} · n={a.rivSampleSize}49 </span>50 </span>51 );52 case 'change1d':53 return <Delta value={a.change1d} />;54 case 'change7d':55 return <Delta value={a.change7d} />;56 case 'change30d':57 return <Delta value={a.change30d} />;58 case 'change1y':59 return <Delta value={a.change1y} />;60 case 'latestSale':61 return a.latestSaleUsd === null ? (62 <span className="text-subtle">—</span>63 ) : (64 <span className="inline-flex flex-col items-end leading-tight">65 <span>{fmtMoney(a.latestSaleUsd)}</span>66 <span className="text-[10px] text-subtle">{fmtRelative(a.latestSaleAt)}</span>67 </span>68 );69 case 'sales':70 return fmtNum(a.salesCount);71 case 'sales30d':72 return fmtNum(a.sales30d);73 case 'listings':74 return fmtNum(a.activeListings);75 case 'minAsk':76 return fmtMoney(a.minAskUsd);77 case 'liquidity':78 return a.liquidityScore === null ? <span className="text-subtle">—</span> : Math.round(a.liquidityScore);79 case 'rarity':80 return a.rarityScore === null ? <span className="text-subtle">—</span> : Math.round(a.rarityScore);81 case 'trending':82 return a.trendingScore === null ? <span className="text-subtle">—</span> : a.trendingScore.toFixed(1);83 case 'opportunity':84 return <VsRiv discount={a.valueOpportunity} showLabel={false} />;85 case 'watchers':86 return fmtNum(a.watchers);87 case 'updated':88 return <span className="text-muted">{fmtRelative(a.updatedAt)}</span>;89 }90}9192export function AssetTable({ items, columns = DEFAULT_COLUMNS, rank = false, startRank = 1, className, emptyTitle = 'No assets yet', emptyDescription = 'Assets appear here as connectors ingest catalogues, sales and listings.' }: { items: AssetCardData[]; columns?: AssetColumn[]; rank?: boolean; startRank?: number; className?: string; emptyTitle?: string; emptyDescription?: string }) {93 if (!items.length) return <EmptyState title={emptyTitle} description={emptyDescription} />;94 return (95 <Table className={className}>96 <thead>97 <tr>98 {rank ? <th className={cn(th, 'w-8 text-right')}>#</th> : null}99 <th className={th}>Asset</th>100 {columns.map((c) => (101 <th key={c} className={cn(th, 'text-right')}>102 {HEAD[c]}103 </th>104 ))}105 </tr>106 </thead>107 <tbody>108 {items.map((a, i) => (109 <tr key={a.id} className="hover:bg-sunken">110 {rank ? <td className={cn(tdNum, 'text-subtle')}>{startRank + i}</td> : null}111 <td className={cn(td, 'max-w-[320px]')}>112 <span className="flex min-w-0 items-center gap-2.5">113 <Thumb src={a.heroImageUrl} alt="" size={32} />114 <span className="min-w-0">115 <Link href={`/asset/${a.slug}`} className="block truncate font-medium text-fg hover:underline" title={a.title}>116 {a.title}117 </Link>118 <span className="block truncate text-[11px] text-muted">119 {catName(a.categorySlug)}120 {a.setName ? ` · ${a.setName}` : ''}121 {a.year ? ` · ${a.year}` : ''}122 </span>123 </span>124 </span>125 </td>126 {columns.map((c) => (127 <td key={c} className={tdNum}>128 {cell(a, c)}129 </td>130 ))}131 </tr>132 ))}133 </tbody>134 </Table>135 );136}137138/** Compact horizontal card for mobile lists and grids: image, title, set/year, honest price line, one metric. */139export function AssetCard({ a, className, metric = 'change30d' }: { a: AssetCardData; className?: string; metric?: 'change30d' | 'change7d' | 'change1d' | 'latestSale' | 'opportunity' | 'trending' }) {140 const value = metric === 'change1d' ? a.change1d : metric === 'change7d' ? a.change7d : metric === 'change30d' ? a.change30d : null;141 const metricNode =142 metric === 'latestSale' ? (143 a.latestSaleUsd !== null && a.rivUsd !== null ? <span className="num text-[11px] text-muted">last {fmtMoney(a.latestSaleUsd)}</span> : a.number ? <span className="num text-[11px] text-subtle">#{a.number}</span> : null144 ) : metric === 'opportunity' ? (145 a.valueOpportunity !== null ? <VsRiv discount={a.valueOpportunity} className="text-[11px]" /> : null146 ) : metric === 'trending' ? (147 a.trendingScore !== null ? <span className="num text-[11px] text-muted">trend {a.trendingScore.toFixed(1)}</span> : null148 ) : value !== null ? (149 <span className="text-[11px] text-muted">150 {metric === 'change1d' ? '1D' : metric === 'change7d' ? '7D' : '30D'} <Delta value={value} arrow className="text-[11px]" />151 </span>152 ) : a.salesCount ? (153 <span className="num text-[11px] text-subtle">{fmtNum(a.salesCount)} sales</span>154 ) : null;155 return (156 <Link href={`/asset/${a.slug}`} className={cn('card card-hover group flex min-h-[88px] gap-3 p-2.5', className)}>157 <span className="w-16 shrink-0">158 <TileImage src={a.heroImageUrl} alt="" familySlug={a.familySlug} sizes="64px" />159 </span>160 <span className="flex min-w-0 flex-1 flex-col">161 <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg" title={a.title}>162 {a.title}163 </span>164 <span className="mt-0.5 truncate text-[11px] text-muted">165 {catName(a.categorySlug)}166 {a.setName ? ` · ${a.setName}` : ''}167 {a.year ? ` · ${a.year}` : ''}168 </span>169 <span className="mt-auto flex items-end justify-between gap-2 pt-1.5">170 <PriceLine a={a} size="sm" />171 <span className="shrink-0 text-right">{metricNode}</span>172 </span>173 </span>174 </Link>175 );176}177178export function AssetCardGrid({ items, metric, className }: { items: AssetCardData[]; metric?: Parameters<typeof AssetCard>[0]['metric']; className?: string }) {179 if (!items.length) return null;180 return (181 <div className={cn('grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', className)}>182 {items.map((a) => (183 <AssetCard key={a.id} a={a} metric={metric} />184 ))}185 </div>186 );187}188189/** Responsive list: cards on small screens, table from md up. */190export function AssetList(props: Parameters<typeof AssetTable>[0] & { metric?: Parameters<typeof AssetCard>[0]['metric']; forceCards?: boolean }) {191 if (!props.items.length) return <AssetTable {...props} />;192 if (props.forceCards) return <AssetCardGrid items={props.items} metric={props.metric} />;193 return (194 <>195 <div className="md:hidden">196 <AssetCardGrid items={props.items} metric={props.metric} />197 </div>198 <div className="hidden md:block">199 <AssetTable {...props} />200 </div>201 </>202 );203}204