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%
5.7 KB · 156 lines tsx
Raw Blame History
1'use client';23import { useState, type CSSProperties } from 'react';4import { cn } from '@/lib/format';5import type { PlaceholderGlyph } from '@/lib/image-glyph';6export { glyphForFamily, type PlaceholderGlyph } from '@/lib/image-glyph';78/**9 * Plain, lazy `<img>` bound to the /img cache (src + srcSet computed on the server) with a10 * designed placeholder — never the browser's broken-image glyph. Falls back on load error.11 */12export interface SmartImageProps {13  src: string | null;14  srcSet?: string | null;15  sizes?: string;16  alt: string;17  fit?: 'contain' | 'cover';18  priority?: boolean;19  className?: string;20  imgClassName?: string;21  style?: CSSProperties;22  placeholder?: { label?: string | null; glyph?: PlaceholderGlyph };23  /** small thumbnail: glyph only */24  compact?: boolean;25}262728function Glyph({ kind, className }: { kind: PlaceholderGlyph; className?: string }) {29  const p = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };30  switch (kind) {31    case 'card':32      return (33        <svg viewBox="0 0 24 24" className={className} aria-hidden {...p}>34          <rect x="5" y="3" width="14" height="18" rx="1.5" />35          <rect x="7.5" y="5.5" width="9" height="7" rx="0.5" />36          <path d="M7.5 15.5h9M7.5 18h6" />37        </svg>38      );39    case 'comic':40      return (41        <svg viewBox="0 0 24 24" className={className} aria-hidden {...p}>42          <rect x="4.5" y="3" width="15" height="18" rx="1" />43          <path d="M4.5 8h15M9 3v18M4.5 15h15" />44        </svg>45      );46    case 'watch':47      return (48        <svg viewBox="0 0 24 24" className={className} aria-hidden {...p}>49          <circle cx="12" cy="12" r="6" />50          <path d="M12 9v3l2 1.5M9.5 6l.7-3h3.6l.7 3M9.5 18l.7 3h3.6l.7-3" />51        </svg>52      );53    case 'brick':54      return (55        <svg viewBox="0 0 24 24" className={className} aria-hidden {...p}>56          <path d="M3 9h18v10H3zM6 9V6.5h4V9M14 9V6.5h4V9" />57        </svg>58      );59    case 'game':60      return (61        <svg viewBox="0 0 24 24" className={className} aria-hidden {...p}>62          <rect x="6" y="3" width="12" height="18" rx="1.5" />63          <rect x="8.5" y="6" width="7" height="6" rx="0.5" />64          <path d="M9 16h6" />65        </svg>66      );67    case 'sneaker':68      return (69        <svg viewBox="0 0 24 24" className={className} aria-hidden {...p}>70          <path d="M3 15c3 0 5-1 7-4l2 1.5c2 1.5 5 1.5 9 3.5v2H3z" />71          <path d="M3 18h18" />72        </svg>73      );74    case 'coin':75      return (76        <svg viewBox="0 0 24 24" className={className} aria-hidden {...p}>77          <circle cx="12" cy="12" r="8" />78          <circle cx="12" cy="12" r="4.5" />79        </svg>80      );81    case 'bottle':82      return (83        <svg viewBox="0 0 24 24" className={className} aria-hidden {...p}>84          <path d="M10 3h4v4l2 3v10a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V10l2-3z" />85          <path d="M8 14h8" />86        </svg>87      );88    case 'car':89      return (90        <svg viewBox="0 0 24 24" className={className} aria-hidden {...p}>91          <path d="M4 14l2-5h12l2 5v4H4z" />92          <circle cx="7.5" cy="17.5" r="1.5" />93          <circle cx="16.5" cy="17.5" r="1.5" />94        </svg>95      );96    case 'toy':97      return (98        <svg viewBox="0 0 24 24" className={className} aria-hidden {...p}>99          <circle cx="12" cy="8" r="4" />100          <path d="M6 21v-4a6 6 0 0 1 12 0v4" />101        </svg>102      );103    case 'book':104      return (105        <svg viewBox="0 0 24 24" className={className} aria-hidden {...p}>106          <path d="M5 4h6a2 2 0 0 1 2 2v14a2 2 0 0 0-2-2H5zM19 4h-6a2 2 0 0 0-2 2v14a2 2 0 0 1 2-2h6z" />107        </svg>108      );109    case 'art':110      return (111        <svg viewBox="0 0 24 24" className={className} aria-hidden {...p}>112          <rect x="3.5" y="4.5" width="17" height="15" rx="1" />113          <path d="M6 17l4-5 3 3 2-2 3 4" />114          <circle cx="15.5" cy="9" r="1.2" />115        </svg>116      );117    default:118      return (119        <svg viewBox="0 0 24 24" className={className} aria-hidden {...p}>120          <path d="M4 8l8-4 8 4v8l-8 4-8-4zM4 8l8 4 8-4M12 12v8" />121        </svg>122      );123  }124}125126export function ImagePlaceholder({ label, glyph = 'box', compact = false, className }: { label?: string | null; glyph?: PlaceholderGlyph; compact?: boolean; className?: string }) {127  return (128    <span className={cn('absolute inset-0 flex flex-col items-center justify-center gap-1 bg-inset text-subtle', className)} role="img" aria-label={label ? `${label} (no image)` : 'No image'}>129      <Glyph kind={glyph} className={compact ? 'h-1/2 w-1/2 max-h-5 max-w-5' : 'h-8 w-8 opacity-70'} />130      {!compact && label ? <span className="t-caption max-w-[80%] truncate text-center">{label}</span> : null}131    </span>132  );133}134135export function SmartImage({ src, srcSet, sizes, alt, fit = 'contain', priority = false, className, imgClassName, style, placeholder, compact = false }: SmartImageProps) {136  const [failed, setFailed] = useState(false);137  if (!src || failed) return <ImagePlaceholder label={placeholder?.label ?? null} glyph={placeholder?.glyph ?? 'box'} compact={compact} className={className} />;138  return (139    // eslint-disable-next-line @next/next/no-img-element140    <img141      src={src}142      srcSet={srcSet ?? undefined}143      sizes={srcSet ? sizes ?? '100vw' : undefined}144      alt={alt}145      loading={priority ? 'eager' : 'lazy'}146      fetchPriority={priority ? 'high' : 'auto'}147      decoding="async"148      referrerPolicy="no-referrer"149      draggable={false}150      onError={() => setFailed(true)}151      style={style}152      className={cn('absolute inset-0 h-full w-full', fit === 'cover' ? 'object-cover' : 'object-contain', imgClassName)}153    />154  );155}156