TwoTh / frontend /src /components /ImagePreviewGrid.tsx
Aravindselvan
Migrate TwoTh to Hugging Face Space pipeline
d325ede
Raw
History Blame Contribute Delete
3.76 kB
/**
* ImagePreviewGrid — Thumbnail grid with remove button.
* On mobile the remove button is always visible (no hover needed).
*/
import { AnimatePresence, motion } from 'framer-motion';
import type { ImageFile } from '../types';
interface ImagePreviewGridProps {
files: ImageFile[];
onRemove: (id: string) => void;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default function ImagePreviewGrid({ files, onRemove }: ImagePreviewGridProps) {
if (files.length === 0) return null;
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs sm:text-sm font-medium text-white/60">
{files.length} image{files.length !== 1 ? 's' : ''} selected
</h3>
{files.length >= 4 && (
<span className="text-xs text-green-400 font-medium">✓ Required views ready</span>
)}
</div>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2 sm:gap-3">
<AnimatePresence mode="popLayout">
{files.map((image, index) => (
<motion.div
key={image.id}
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.18 }}
className="group relative"
>
<div className="relative aspect-square rounded-xl overflow-hidden bg-white/5 border border-white/10">
<img
src={image.thumbnail}
alt={image.filename}
className="w-full h-full object-cover"
loading="lazy"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
{/* Bottom info */}
<div className="absolute bottom-0 left-0 right-0 p-1.5">
<p className="text-[10px] text-white/80 truncate leading-tight">{image.label}</p>
<p className="text-[9px] text-white/40">{formatFileSize(image.size)}</p>
</div>
{/* Index badge */}
<span className="absolute top-1.5 left-1.5 px-1.5 py-0.5 text-[9px] rounded-md bg-indigo-500/80 text-white font-medium">
{index + 1}
</span>
{/* Remove button — always visible on touch, hover on desktop */}
<button
onClick={() => onRemove(image.id)}
className="
absolute top-1.5 right-1.5
w-6 h-6 rounded-full
bg-black/60 hover:bg-red-500
flex items-center justify-center
/* Always show on touch devices, hover-reveal on desktop */
opacity-100 sm:opacity-0 sm:group-hover:opacity-100
transition-all duration-150
touch-manipulation
"
aria-label={`Remove ${image.filename}`}
>
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</motion.div>
);
}