MogensR's picture
Create web/src/components/upload-zone.tsx
3a2dfa1
raw
history blame
6.93 kB
'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>
)
}