| 'use client' | |
| import { useCallback, useState } from 'react' | |
| import { useDropzone } from 'react-dropzone' | |
| import { motion, AnimatePresence } from 'framer-motion' | |
| import { Upload, Image, Video, FileText, X, AlertCircle } from 'lucide-react' | |
| import { cn } from '@/lib/utils' | |
| import { Button } from './ui/button' | |
| import toast from 'react-hot-toast' | |
| interface UploadZoneProps { | |
| onUpload?: (files: File[]) => void | |
| onDragChange?: (isDragging: boolean) => void | |
| accept?: Record<string, string[]> | |
| maxSize?: number | |
| maxFiles?: number | |
| className?: string | |
| } | |
| export function UploadZone({ | |
| onUpload, | |
| onDragChange, | |
| accept = { | |
| 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp'], | |
| 'video/*': ['.mp4', '.webm', '.mov'], | |
| }, | |
| maxSize = 50 * 1024 * 1024, // 50MB | |
| maxFiles = 10, | |
| className, | |
| }: UploadZoneProps) { | |
| const [files, setFiles] = useState<File[]>([]) | |
| const [isProcessing, setIsProcessing] = useState(false) | |
| const onDrop = useCallback( | |
| async (acceptedFiles: File[], rejectedFiles: any[]) => { | |
| if (rejectedFiles.length > 0) { | |
| const errors = rejectedFiles.map((file) => { | |
| if (file.errors[0]?.code === 'file-too-large') { | |
| return `${file.file.name} is too large (max ${maxSize / 1024 / 1024}MB)` | |
| } | |
| return `${file.file.name} is not supported` | |
| }) | |
| toast.error(errors.join(', ')) | |
| return | |
| } | |
| setFiles(acceptedFiles) | |
| if (onUpload) { | |
| setIsProcessing(true) | |
| try { | |
| await onUpload(acceptedFiles) | |
| } catch (error) { | |
| toast.error('Failed to upload files') | |
| } finally { | |
| setIsProcessing(false) | |
| } | |
| } | |
| }, | |
| [onUpload, maxSize] | |
| ) | |
| const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ | |
| onDrop, | |
| accept, | |
| maxSize, | |
| maxFiles, | |
| onDragEnter: () => onDragChange?.(true), | |
| onDragLeave: () => onDragChange?.(false), | |
| onDropAccepted: () => onDragChange?.(false), | |
| onDropRejected: () => onDragChange?.(false), | |
| }) | |
| const getFileIcon = (file: File) => { | |
| if (file.type.startsWith('image/')) return <Image className="w-4 h-4" /> | |
| if (file.type.startsWith('video/')) return <Video className="w-4 h-4" /> | |
| return <FileText className="w-4 h-4" /> | |
| } | |
| const removeFile = (index: number) => { | |
| setFiles((prev) => prev.filter((_, i) => i !== index)) | |
| } | |
| return ( | |
| <div className={className}> | |
| <div | |
| {...getRootProps()} | |
| className={cn( | |
| 'relative rounded-xl border-2 border-dashed transition-all duration-200 cursor-pointer', | |
| 'bg-gray-800/50 hover:bg-gray-800/70', | |
| isDragActive && !isDragReject && 'border-purple-500 bg-purple-500/10', | |
| isDragReject && 'border-red-500 bg-red-500/10', | |
| !isDragActive && !isDragReject && 'border-gray-700', | |
| isProcessing && 'pointer-events-none opacity-50' | |
| )} | |
| > | |
| <input {...getInputProps()} /> | |
| <div className="p-12 text-center"> | |
| <AnimatePresence mode="wait"> | |
| {isDragReject ? ( | |
| <motion.div | |
| key="reject" | |
| initial={{ opacity: 0, scale: 0.9 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| exit={{ opacity: 0, scale: 0.9 }} | |
| className="text-red-500" | |
| > | |
| <AlertCircle className="w-12 h-12 mx-auto mb-4" /> | |
| <p className="text-lg font-medium">File not supported</p> | |
| <p className="text-sm mt-2 text-red-400"> | |
| Please upload image or video files only | |
| </p> | |
| </motion.div> | |
| ) : isDragActive ? ( | |
| <motion.div | |
| key="active" | |
| initial={{ opacity: 0, scale: 0.9 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| exit={{ opacity: 0, scale: 0.9 }} | |
| className="text-purple-400" | |
| > | |
| <Upload className="w-12 h-12 mx-auto mb-4 animate-bounce" /> | |
| <p className="text-lg font-medium">Drop files here</p> | |
| <p className="text-sm mt-2 text-purple-300"> | |
| Release to upload | |
| </p> | |
| </motion.div> | |
| ) : ( | |
| <motion.div | |
| key="default" | |
| initial={{ opacity: 0, scale: 0.9 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| exit={{ opacity: 0, scale: 0.9 }} | |
| > | |
| <Upload className="w-12 h-12 mx-auto mb-4 text-gray-500" /> | |
| <p className="text-lg font-medium text-white mb-2"> | |
| Drop files here or click to browse | |
| </p> | |
| <p className="text-sm text-gray-400"> | |
| Support for PNG, JPG, GIF, WebP, MP4, MOV | |
| </p> | |
| <p className="text-xs text-gray-500 mt-2"> | |
| Max {maxSize / 1024 / 1024}MB per file • Up to {maxFiles} files | |
| </p> | |
| <Button className="mt-6" variant="outline"> | |
| Select Files | |
| </Button> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| {isProcessing && ( | |
| <div className="absolute inset-0 bg-gray-900/80 rounded-xl flex items-center justify-center"> | |
| <div className="text-center"> | |
| <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mb-4"></div> | |
| <p className="text-white">Processing...</p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* File List */} | |
| {files.length > 0 && ( | |
| <div className="mt-4 space-y-2"> | |
| <p className="text-sm font-medium text-gray-300 mb-2"> | |
| Selected files ({files.length}) | |
| </p> | |
| {files.map((file, index) => ( | |
| <motion.div | |
| key={`${file.name}-${index}`} | |
| initial={{ opacity: 0, x: -20 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| transition={{ delay: index * 0.05 }} | |
| className="flex items-center gap-3 p-3 bg-gray-800 rounded-lg" | |
| > | |
| {getFileIcon(file)} | |
| <div className="flex-1 min-w-0"> | |
| <p className="text-sm font-medium text-white truncate"> | |
| {file.name} | |
| </p> | |
| <p className="text-xs text-gray-400"> | |
| {(file.size / 1024 / 1024).toFixed(2)} MB | |
| </p> | |
| </div> | |
| <Button | |
| size="sm" | |
| variant="ghost" | |
| onClick={() => removeFile(index)} | |
| className="text-gray-400 hover:text-red-400" | |
| > | |
| <X className="w-4 h-4" /> | |
| </Button> | |
| </motion.div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } |