Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from "react"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { Upload, FileText, Image, FileSpreadsheet, X, Sparkles, AlertCircle } from "lucide-react"; | |
| import { cn } from "@/lib/utils"; | |
| import { Input } from "@/components/ui/input"; | |
| // Allowed file types | |
| const ALLOWED_TYPES = [ | |
| "application/pdf", | |
| "image/png", | |
| "image/jpeg", | |
| "image/jpg", | |
| "image/tiff", | |
| "image/tif" | |
| ]; | |
| // Allowed file extensions (for fallback validation) | |
| const ALLOWED_EXTENSIONS = [".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".tif"]; | |
| // Maximum file size: 4 MB | |
| const MAX_FILE_SIZE = 4 * 1024 * 1024; // 4 MB in bytes | |
| export default function UploadZone({ onFileSelect, selectedFile, onClear, keyFields = "", onKeyFieldsChange = () => {} }) { | |
| const [isDragging, setIsDragging] = useState(false); | |
| const [error, setError] = useState(null); | |
| const validateFile = (file) => { | |
| // Reset error | |
| setError(null); | |
| // Check file type | |
| const fileExtension = "." + file.name.split(".").pop().toLowerCase(); | |
| const isValidType = ALLOWED_TYPES.includes(file.type) || ALLOWED_EXTENSIONS.includes(fileExtension); | |
| if (!isValidType) { | |
| setError("Only PDF, PNG, JPG, and TIFF files are allowed."); | |
| return false; | |
| } | |
| // Check file size | |
| if (file.size > MAX_FILE_SIZE) { | |
| const fileSizeMB = (file.size / 1024 / 1024).toFixed(2); | |
| setError(`File size exceeds 4 MB limit. Your file is ${fileSizeMB} MB.`); | |
| return false; | |
| } | |
| return true; | |
| }; | |
| const handleFileSelect = (file) => { | |
| if (validateFile(file)) { | |
| setError(null); | |
| onFileSelect(file); | |
| } | |
| }; | |
| const handleDragOver = (e) => { | |
| e.preventDefault(); | |
| setIsDragging(true); | |
| }; | |
| const handleDragLeave = () => { | |
| setIsDragging(false); | |
| }; | |
| const handleDrop = (e) => { | |
| e.preventDefault(); | |
| setIsDragging(false); | |
| const file = e.dataTransfer.files[0]; | |
| if (file) { | |
| handleFileSelect(file); | |
| } | |
| }; | |
| const getFileIcon = (type) => { | |
| if (type?.includes("image")) return Image; | |
| if (type?.includes("spreadsheet") || type?.includes("excel")) return FileSpreadsheet; | |
| return FileText; | |
| }; | |
| const FileIcon = selectedFile ? getFileIcon(selectedFile.type) : FileText; | |
| // Clear error when file is cleared | |
| useEffect(() => { | |
| if (!selectedFile) { | |
| setError(null); | |
| } | |
| }, [selectedFile]); | |
| return ( | |
| <div className="w-full"> | |
| <AnimatePresence mode="wait"> | |
| {!selectedFile ? ( | |
| <motion.div | |
| key="upload" | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, y: -10 }} | |
| transition={{ duration: 0.2 }} | |
| onDragOver={handleDragOver} | |
| onDragLeave={handleDragLeave} | |
| onDrop={handleDrop} | |
| className={cn( | |
| "relative group cursor-pointer", | |
| "border-2 border-dashed rounded-2xl", | |
| "transition-all duration-300 ease-out", | |
| isDragging | |
| ? "border-indigo-400 bg-indigo-50/50" | |
| : "border-slate-200 hover:border-indigo-300 hover:bg-slate-50/50" | |
| )} | |
| > | |
| <label className="flex flex-col items-center justify-center py-16 px-8 cursor-pointer"> | |
| <motion.div | |
| animate={isDragging ? { scale: 1.1, y: -5 } : { scale: 1, y: 0 }} | |
| className={cn( | |
| "h-16 w-16 rounded-2xl flex items-center justify-center mb-6 transition-colors duration-300", | |
| isDragging | |
| ? "bg-indigo-100" | |
| : "bg-gradient-to-br from-slate-100 to-slate-50 group-hover:from-indigo-100 group-hover:to-violet-50" | |
| )} | |
| > | |
| <Upload | |
| className={cn( | |
| "h-7 w-7 transition-colors duration-300", | |
| isDragging ? "text-indigo-600" : "text-slate-400 group-hover:text-indigo-500" | |
| )} | |
| /> | |
| </motion.div> | |
| <div className="text-center"> | |
| <p className="text-lg font-semibold text-slate-700 mb-1"> | |
| {isDragging ? "Drop your file here" : "Drop your file here, or browse"} | |
| </p> | |
| <p className="text-sm text-slate-400"> | |
| Supports PDF, PNG, JPG, TIFF up to 4MB | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-2 mt-6"> | |
| <div className="flex -space-x-1"> | |
| {[ | |
| "bg-red-100 text-red-600", | |
| "bg-blue-100 text-blue-600", | |
| "bg-green-100 text-green-600", | |
| "bg-amber-100 text-amber-600", | |
| ].map((color, i) => ( | |
| <div | |
| key={i} | |
| className={`h-8 w-8 rounded-lg ${color.split(" ")[0]} flex items-center justify-center border-2 border-white`} | |
| > | |
| <FileText className={`h-4 w-4 ${color.split(" ")[1]}`} /> | |
| </div> | |
| ))} | |
| </div> | |
| <span className="text-xs text-slate-400 ml-2">Multiple formats supported</span> | |
| </div> | |
| <input | |
| type="file" | |
| className="hidden" | |
| accept=".pdf,.png,.jpg,.jpeg,.tiff,.tif" | |
| onChange={(e) => { | |
| const file = e.target.files[0]; | |
| if (file) { | |
| handleFileSelect(file); | |
| } | |
| // Reset input so same file can be selected again after error | |
| e.target.value = ""; | |
| }} | |
| /> | |
| </label> | |
| {/* Decorative gradient border on hover */} | |
| <div className="absolute inset-0 -z-10 rounded-2xl bg-gradient-to-r from-indigo-500 via-violet-500 to-purple-500 opacity-0 group-hover:opacity-10 blur-xl transition-opacity duration-500" /> | |
| </motion.div> | |
| ) : ( | |
| <motion.div | |
| key="selected" | |
| initial={{ opacity: 0, scale: 0.95 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| exit={{ opacity: 0, scale: 0.95 }} | |
| className="grid grid-cols-1 lg:grid-cols-2 gap-3" | |
| > | |
| {/* File Info Box */} | |
| <div className="relative bg-gradient-to-br from-indigo-50 to-violet-50 rounded-xl p-3 border border-indigo-100"> | |
| <div className="flex items-center gap-3"> | |
| <div className="h-10 w-10 rounded-lg bg-white shadow-sm flex items-center justify-center flex-shrink-0"> | |
| <FileIcon className="h-5 w-5 text-indigo-600" /> | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <p className="font-medium text-slate-800 truncate text-sm">{selectedFile.name}</p> | |
| <div className="flex items-center gap-2 text-xs text-slate-500"> | |
| <span>{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</span> | |
| <span className="text-indigo-500">•</span> | |
| <span className="text-indigo-600 flex items-center gap-1"> | |
| <Sparkles className="h-3 w-3" /> | |
| Ready for extraction | |
| </span> | |
| </div> | |
| </div> | |
| <button | |
| onClick={onClear} | |
| className="h-8 w-8 rounded-lg bg-white hover:bg-red-50 border border-slate-200 hover:border-red-200 flex items-center justify-center text-slate-400 hover:text-red-500 transition-colors" | |
| > | |
| <X className="h-4 w-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Key Fields Box */} | |
| <div className="relative bg-white rounded-xl p-3 border border-slate-200"> | |
| <label className="block text-xs font-medium text-slate-600 mb-1.5"> | |
| <span className="font-bold">Key Fields</span> <span className="font-normal">(if required)</span> | |
| </label> | |
| <Input | |
| type="text" | |
| value={keyFields || ""} | |
| onChange={(e) => { | |
| if (onKeyFieldsChange) { | |
| onKeyFieldsChange(e.target.value); | |
| } | |
| }} | |
| placeholder="Invoice Number, Invoice Date, PO Number, Supplier Name, Total Amount, Payment terms, Additional Notes" | |
| className="h-8 text-xs border-slate-200 focus:border-indigo-300 focus:ring-indigo-200" | |
| /> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* Error Message */} | |
| {error && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: -10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, y: -10 }} | |
| className="mt-3 p-3 bg-red-50 border border-red-200 rounded-xl flex items-start gap-2" | |
| > | |
| <AlertCircle className="h-4 w-4 text-red-600 flex-shrink-0 mt-0.5" /> | |
| <p className="text-sm text-red-700 flex-1">{error}</p> | |
| <button | |
| onClick={() => setError(null)} | |
| className="text-red-600 hover:text-red-800 transition-colors" | |
| > | |
| <X className="h-4 w-4" /> | |
| </button> | |
| </motion.div> | |
| )} | |
| </div> | |
| ); | |
| } | |