/** * ImageUploader Component * Drag-and-drop image upload zone with preview. */ import { useState, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Upload, X, Image as ImageIcon, Loader2, AlertCircle } from "lucide-react"; import { ImageCropper } from "@/components/image-cropper"; interface ImageUploaderProps { existingUrl?: string | null; onFileSelect: (file: File | null) => void; disabled?: boolean; maxSizeMB?: number; } const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; const MAX_SIZE_DEFAULT = 15; // 15MB export function ImageUploader({ existingUrl, onFileSelect, disabled, maxSizeMB = MAX_SIZE_DEFAULT, }: ImageUploaderProps) { const [previewUrl, setPreviewUrl] = useState(existingUrl || null); const [isDragging, setIsDragging] = useState(false); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); // Cropper state const [isCropperOpen, setIsCropperOpen] = useState(false); const [tempImageSrc, setTempImageSrc] = useState(null); const [tempFileName, setTempFileName] = useState("image.jpg"); const inputRef = useRef(null); const validateFile = useCallback((file: File): string | null => { if (!ACCEPTED_TYPES.includes(file.type)) { return "Invalid file type. Please use PNG, JPEG, WebP, or GIF."; } if (file.size > maxSizeMB * 1024 * 1024) { return `File too large. Maximum size is ${maxSizeMB}MB.`; } return null; }, [maxSizeMB]); const handleFile = useCallback((file: File) => { setError(null); const validationError = validateFile(file); if (validationError) { setError(validationError); return; } setIsLoading(true); setTempFileName(file.name); // Read file for cropper const reader = new FileReader(); reader.onload = () => { setTempImageSrc(reader.result as string); setIsCropperOpen(true); setIsLoading(false); }; reader.onerror = () => { setError("Failed to read file"); setIsLoading(false); }; reader.readAsDataURL(file); }, [validateFile]); const handleCropComplete = useCallback((croppedBlob: Blob) => { // Convert blob back to file const file = new File([croppedBlob], tempFileName, { type: "image/jpeg" }); // Create preview const url = URL.createObjectURL(croppedBlob); setPreviewUrl(url); // Pass to parent onFileSelect(file); setIsCropperOpen(false); setTempImageSrc(null); }, [onFileSelect, tempFileName]); const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (!disabled) { setIsDragging(true); } }, [disabled]); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }, []); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); if (disabled) return; const files = e.dataTransfer.files; const file = files[0]; if (file) { handleFile(file); } }, [disabled, handleFile]); const handleInputChange = useCallback((e: React.ChangeEvent) => { const files = e.target.files; const file = files?.[0]; if (file) { handleFile(file); } }, [handleFile]); const handleRemove = useCallback(() => { setPreviewUrl(null); setError(null); onFileSelect(null); if (inputRef.current) { inputRef.current.value = ""; } }, [onFileSelect]); const handleClick = useCallback(() => { if (!disabled) { inputRef.current?.click(); } }, [disabled]); // Show preview state if (previewUrl) { return (
Item preview {!disabled && (
)}
); } // Show upload zone return (
{isLoading ? ( ) : error ? ( ) : (
{isDragging ? ( ) : ( )}
{isDragging ? (

Drop to upload

) : ( <>

Drop image or click to browse

PNG, JPEG, WebP, GIF • Max {maxSizeMB}MB

)}
)}
{error && (

{error}

)} setIsCropperOpen(false)} onCropComplete={handleCropComplete} imageSrc={tempImageSrc} />
); } export default ImageUploader;