| import { useState, useCallback, useRef } from "react"; |
| import { Upload, FileArchive, X, AlertCircle, Package } from "lucide-react"; |
|
|
| interface ZipUploaderProps { |
| onFilesReady: (files: File[], zipName: string) => void; |
| disabled?: boolean; |
| } |
|
|
| export function ZipUploader({ onFilesReady, disabled }: ZipUploaderProps) { |
| const [dragActive, setDragActive] = useState(false); |
| const [error, setError] = useState<string | null>(null); |
| const [zipInfo, setZipInfo] = useState<{ name: string; size: string } | null>(null); |
| const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
| const processZip = useCallback( |
| async (file: File) => { |
| if (!file.name.toLowerCase().endsWith(".zip")) { |
| setError("Please upload a .zip file containing car images."); |
| return; |
| } |
|
|
| setError(null); |
| setZipInfo({ |
| name: file.name, |
| size: `${(file.size / 1024 / 1024).toFixed(2)} MB`, |
| }); |
|
|
| const JSZip = (await import("jszip")).default; |
| const zip = await JSZip.loadAsync(file); |
|
|
| const imageFiles: File[] = []; |
| const IMAGE_EXTS = /\.(jpe?g|png|webp|gif|bmp)$/i; |
|
|
| const entries = Object.entries(zip.files).filter( |
| ([name, entry]) => !entry.dir && IMAGE_EXTS.test(name) |
| ); |
|
|
| if (entries.length === 0) { |
| setError("No image files found inside the ZIP archive."); |
| setZipInfo(null); |
| return; |
| } |
|
|
| for (const [name, entry] of entries) { |
| const blob = await entry.async("blob"); |
| const basename = name.split("/").pop() ?? name; |
| const ext = basename.split(".").pop()?.toLowerCase() ?? "jpg"; |
| const mimeMap: Record<string, string> = { |
| jpg: "image/jpeg", |
| jpeg: "image/jpeg", |
| png: "image/png", |
| webp: "image/webp", |
| gif: "image/gif", |
| bmp: "image/bmp", |
| }; |
| const imgFile = new File([blob], basename, { |
| type: mimeMap[ext] ?? "image/jpeg", |
| }); |
| imageFiles.push(imgFile); |
| } |
|
|
| onFilesReady(imageFiles, file.name); |
| }, |
| [onFilesReady] |
| ); |
|
|
| const handleDrop = useCallback( |
| (e: React.DragEvent) => { |
| e.preventDefault(); |
| setDragActive(false); |
| const file = e.dataTransfer.files[0]; |
| if (file) processZip(file); |
| }, |
| [processZip] |
| ); |
|
|
| const handleFileSelect = useCallback( |
| (e: React.ChangeEvent<HTMLInputElement>) => { |
| const file = e.target.files?.[0]; |
| if (file) processZip(file); |
| e.target.value = ""; |
| }, |
| [processZip] |
| ); |
|
|
| const clearSelection = () => { |
| setZipInfo(null); |
| setError(null); |
| }; |
|
|
| return ( |
| <div className="w-full"> |
| {/* Drop Zone */} |
| <div |
| onClick={() => !disabled && fileInputRef.current?.click()} |
| onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragActive(true); }} |
| onDragLeave={() => setDragActive(false)} |
| onDrop={disabled ? undefined : handleDrop} |
| className={[ |
| "relative cursor-pointer rounded-2xl border-2 border-dashed transition-all duration-300 overflow-hidden", |
| "group select-none", |
| disabled |
| ? "opacity-50 cursor-not-allowed border-surface-500" |
| : dragActive |
| ? "border-brand-500 bg-brand-500/10 scale-[1.01] shadow-[0_0_40px_rgba(26,107,255,0.2)]" |
| : zipInfo |
| ? "border-emerald-500/60 bg-emerald-500/5 hover:border-emerald-400" |
| : "border-surface-500 hover:border-brand-500/60 hover:bg-brand-500/5", |
| ].join(" ")} |
| > |
| {/* Animated background grid */} |
| <div className="absolute inset-0 bg-grid-pattern bg-[size:32px_32px] opacity-20 pointer-events-none" /> |
| |
| <div className="relative z-10 flex flex-col items-center justify-center py-16 px-8 text-center gap-6"> |
| {zipInfo ? ( |
| <> |
| <div className="relative"> |
| <div className="w-20 h-20 rounded-2xl bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center"> |
| <FileArchive className="w-10 h-10 text-emerald-400" /> |
| </div> |
| <div className="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-emerald-500 flex items-center justify-center"> |
| <span className="text-white text-xs font-bold">✓</span> |
| </div> |
| </div> |
| <div> |
| <p className="text-white font-semibold text-lg">{zipInfo.name}</p> |
| <p className="text-slate-400 text-sm mt-1">{zipInfo.size} · Ready to classify</p> |
| </div> |
| <p className="text-emerald-400 text-sm font-medium"> |
| Click to replace with a different ZIP |
| </p> |
| </> |
| ) : ( |
| <> |
| <div |
| className={[ |
| "w-24 h-24 rounded-2xl flex items-center justify-center transition-all duration-300", |
| "bg-surface-600 border border-surface-500", |
| "group-hover:bg-brand-500/20 group-hover:border-brand-500/40", |
| dragActive ? "bg-brand-500/20 border-brand-500/40 scale-110" : "", |
| ].join(" ")} |
| > |
| {dragActive ? ( |
| <Package className="w-12 h-12 text-brand-400 animate-bounce-subtle" /> |
| ) : ( |
| <Upload className="w-12 h-12 text-slate-400 group-hover:text-brand-400 transition-colors" /> |
| )} |
| </div> |
| |
| <div className="space-y-2"> |
| <h3 className="text-2xl font-bold text-white"> |
| {dragActive ? "Drop your ZIP here" : "Upload Car Photos ZIP"} |
| </h3> |
| <p className="text-slate-400 text-base max-w-md"> |
| Drag & drop a <span className="text-brand-400 font-semibold">.zip archive</span> containing |
| car photos, or <span className="text-brand-400 font-semibold">click to browse</span> |
| </p> |
| </div> |
| |
| <div className="flex items-center gap-6 text-sm text-slate-500"> |
| <div className="flex items-center gap-2"> |
| <div className="w-1.5 h-1.5 rounded-full bg-brand-500" /> |
| <span>JPG, PNG, WEBP supported</span> |
| </div> |
| <div className="flex items-center gap-2"> |
| <div className="w-1.5 h-1.5 rounded-full bg-brand-500" /> |
| <span>Multiple images in one ZIP</span> |
| </div> |
| <div className="flex items-center gap-2"> |
| <div className="w-1.5 h-1.5 rounded-full bg-brand-500" /> |
| <span>Detects 8 car angles</span> |
| </div> |
| </div> |
| </> |
| )} |
| </div> |
|
|
| <input |
| ref={fileInputRef} |
| type="file" |
| accept=".zip" |
| onChange={handleFileSelect} |
| className="hidden" |
| disabled={disabled} |
| /> |
| </div> |
|
|
| {} |
| {error && ( |
| <div className="mt-4 flex items-center gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 animate-fade-in"> |
| <AlertCircle className="w-5 h-5 flex-shrink-0" /> |
| <span className="text-sm">{error}</span> |
| <button |
| onClick={clearSelection} |
| className="ml-auto hover:text-red-300 transition-colors" |
| > |
| <X className="w-4 h-4" /> |
| </button> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|