| '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> |
| ) |
| } |