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%
8.8 KB · 204 lines tsx
Raw Blame History
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