TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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