Spaces:
Sleeping
Sleeping
File size: 4,872 Bytes
e327f0d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | import type { ReactNode } from 'react';
import { Car, ImageOff } from 'lucide-react';
import { cn } from '../utils/cn';
export interface ThumbnailItem {
/** Backend image URL — null/undefined renders placeholder */
url?: string | null;
/** Alt text for accessibility */
alt?: string;
/** Optional badge content rendered top-right (e.g. damage count) */
badge?: ReactNode;
/** Optional label rendered bottom (e.g. "Ön sol") */
label?: string;
}
interface Props {
items: ThumbnailItem[];
className?: string;
/** Click handler — called with item index */
onItemClick?: (index: number) => void;
/** Aspect ratio class — defaults to 4:3 for vehicle photos */
aspectRatioClass?: string;
}
/**
* Responsive thumbnail grid for multi-photo inspections.
* - 3 columns on mobile, 4 on sm, 6 on lg
* - Fixed aspect ratio reserves space (prevents CLS)
* - Renders car-icon placeholder when image url is missing
*/
export function MultiImageThumbnailGrid({
items,
className,
onItemClick,
aspectRatioClass = 'aspect-[4/3]',
}: Props) {
if (!items || items.length === 0) return null;
return (
<ul
role="list"
className={cn(
'grid grid-cols-3 gap-2 sm:grid-cols-4 sm:gap-3 lg:grid-cols-6',
className,
)}
>
{items.map((item, i) => {
const interactive = !!onItemClick;
const hasUrl = !!item.url;
const alt = item.alt ?? `Fotoğraf ${i + 1}`;
const content = (
<>
<div
className={cn(
'relative w-full overflow-hidden rounded-lg bg-slate-100 ring-1 ring-inset ring-slate-200',
aspectRatioClass,
interactive &&
'transition-shadow group-hover:shadow-md group-focus-visible:shadow-md',
)}
>
{hasUrl ? (
// packages/ui is framework-agnostic — plain <img> with
// explicit width/height reserves space (parent aspect ratio
// prevents CLS). Consumer apps can wrap with next/image if
// they need optimised srcSet.
<img
src={item.url!}
alt={alt}
width={400}
height={300}
loading="lazy"
decoding="async"
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<ThumbnailPlaceholder index={i} />
)}
{item.badge && (
<span className="absolute right-1.5 top-1.5 inline-flex items-center rounded-full bg-slate-900/85 px-1.5 py-0.5 text-[10px] font-semibold text-white shadow-sm">
{item.badge}
</span>
)}
</div>
{item.label && (
<div className="mt-1.5 truncate text-center text-[11px] font-medium text-slate-600">
{item.label}
</div>
)}
</>
);
return (
<li key={i}>
{interactive ? (
<button
type="button"
onClick={() => onItemClick?.(i)}
aria-label={item.label ? `${item.label} — ${alt}` : alt}
className="group block w-full rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2"
>
{content}
</button>
) : (
<div>{content}</div>
)}
</li>
);
})}
</ul>
);
}
function ThumbnailPlaceholder({ index }: { index: number }) {
return (
<div
className="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-gradient-to-br from-slate-50 to-slate-100 text-slate-400"
aria-hidden
>
<Car className="h-6 w-6 sm:h-7 sm:w-7" />
<span className="text-[10px] font-medium uppercase tracking-wider">
#{index + 1}
</span>
</div>
);
}
/**
* Compact placeholder for history rows when no thumbnail is available.
* Use as standalone (square) icon block.
*/
export function ThumbnailFallback({
className,
size = 'md',
}: {
className?: string;
size?: 'sm' | 'md' | 'lg';
}) {
const dimensions =
size === 'sm' ? 'h-10 w-10' : size === 'lg' ? 'h-20 w-20' : 'h-14 w-14';
const iconSize =
size === 'sm' ? 'h-5 w-5' : size === 'lg' ? 'h-9 w-9' : 'h-7 w-7';
return (
<div
className={cn(
'flex flex-none items-center justify-center rounded-lg bg-gradient-to-br from-brand-50 to-brand-100 text-brand-600 ring-1 ring-inset ring-brand-200',
dimensions,
className,
)}
role="img"
aria-label="Görsel hazırlanıyor"
>
<ImageOff className={iconSize} aria-hidden />
</div>
);
}
|