MogensR commited on
Commit
3a2dfa1
·
1 Parent(s): 5883557

Create web/src/components/upload-zone.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/upload-zone.tsx +202 -0
web/src/components/upload-zone.tsx ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useCallback, useState } from 'react'
4
+ import { useDropzone } from 'react-dropzone'
5
+ import { motion, AnimatePresence } from 'framer-motion'
6
+ import { Upload, Image, Video, FileText, X, AlertCircle } from 'lucide-react'
7
+ import { cn } from '@/lib/utils'
8
+ import { Button } from './ui/button'
9
+ import toast from 'react-hot-toast'
10
+
11
+ interface UploadZoneProps {
12
+ onUpload?: (files: File[]) => void
13
+ onDragChange?: (isDragging: boolean) => void
14
+ accept?: Record<string, string[]>
15
+ maxSize?: number
16
+ maxFiles?: number
17
+ className?: string
18
+ }
19
+
20
+ export function UploadZone({
21
+ onUpload,
22
+ onDragChange,
23
+ accept = {
24
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp'],
25
+ 'video/*': ['.mp4', '.webm', '.mov'],
26
+ },
27
+ maxSize = 50 * 1024 * 1024, // 50MB
28
+ maxFiles = 10,
29
+ className,
30
+ }: UploadZoneProps) {
31
+ const [files, setFiles] = useState<File[]>([])
32
+ const [isProcessing, setIsProcessing] = useState(false)
33
+
34
+ const onDrop = useCallback(
35
+ async (acceptedFiles: File[], rejectedFiles: any[]) => {
36
+ if (rejectedFiles.length > 0) {
37
+ const errors = rejectedFiles.map((file) => {
38
+ if (file.errors[0]?.code === 'file-too-large') {
39
+ return `${file.file.name} is too large (max ${maxSize / 1024 / 1024}MB)`
40
+ }
41
+ return `${file.file.name} is not supported`
42
+ })
43
+ toast.error(errors.join(', '))
44
+ return
45
+ }
46
+
47
+ setFiles(acceptedFiles)
48
+
49
+ if (onUpload) {
50
+ setIsProcessing(true)
51
+ try {
52
+ await onUpload(acceptedFiles)
53
+ } catch (error) {
54
+ toast.error('Failed to upload files')
55
+ } finally {
56
+ setIsProcessing(false)
57
+ }
58
+ }
59
+ },
60
+ [onUpload, maxSize]
61
+ )
62
+
63
+ const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
64
+ onDrop,
65
+ accept,
66
+ maxSize,
67
+ maxFiles,
68
+ onDragEnter: () => onDragChange?.(true),
69
+ onDragLeave: () => onDragChange?.(false),
70
+ onDropAccepted: () => onDragChange?.(false),
71
+ onDropRejected: () => onDragChange?.(false),
72
+ })
73
+
74
+ const getFileIcon = (file: File) => {
75
+ if (file.type.startsWith('image/')) return <Image className="w-4 h-4" />
76
+ if (file.type.startsWith('video/')) return <Video className="w-4 h-4" />
77
+ return <FileText className="w-4 h-4" />
78
+ }
79
+
80
+ const removeFile = (index: number) => {
81
+ setFiles((prev) => prev.filter((_, i) => i !== index))
82
+ }
83
+
84
+ return (
85
+ <div className={className}>
86
+ <div
87
+ {...getRootProps()}
88
+ className={cn(
89
+ 'relative rounded-xl border-2 border-dashed transition-all duration-200 cursor-pointer',
90
+ 'bg-gray-800/50 hover:bg-gray-800/70',
91
+ isDragActive && !isDragReject && 'border-purple-500 bg-purple-500/10',
92
+ isDragReject && 'border-red-500 bg-red-500/10',
93
+ !isDragActive && !isDragReject && 'border-gray-700',
94
+ isProcessing && 'pointer-events-none opacity-50'
95
+ )}
96
+ >
97
+ <input {...getInputProps()} />
98
+
99
+ <div className="p-12 text-center">
100
+ <AnimatePresence mode="wait">
101
+ {isDragReject ? (
102
+ <motion.div
103
+ key="reject"
104
+ initial={{ opacity: 0, scale: 0.9 }}
105
+ animate={{ opacity: 1, scale: 1 }}
106
+ exit={{ opacity: 0, scale: 0.9 }}
107
+ className="text-red-500"
108
+ >
109
+ <AlertCircle className="w-12 h-12 mx-auto mb-4" />
110
+ <p className="text-lg font-medium">File not supported</p>
111
+ <p className="text-sm mt-2 text-red-400">
112
+ Please upload image or video files only
113
+ </p>
114
+ </motion.div>
115
+ ) : isDragActive ? (
116
+ <motion.div
117
+ key="active"
118
+ initial={{ opacity: 0, scale: 0.9 }}
119
+ animate={{ opacity: 1, scale: 1 }}
120
+ exit={{ opacity: 0, scale: 0.9 }}
121
+ className="text-purple-400"
122
+ >
123
+ <Upload className="w-12 h-12 mx-auto mb-4 animate-bounce" />
124
+ <p className="text-lg font-medium">Drop files here</p>
125
+ <p className="text-sm mt-2 text-purple-300">
126
+ Release to upload
127
+ </p>
128
+ </motion.div>
129
+ ) : (
130
+ <motion.div
131
+ key="default"
132
+ initial={{ opacity: 0, scale: 0.9 }}
133
+ animate={{ opacity: 1, scale: 1 }}
134
+ exit={{ opacity: 0, scale: 0.9 }}
135
+ >
136
+ <Upload className="w-12 h-12 mx-auto mb-4 text-gray-500" />
137
+ <p className="text-lg font-medium text-white mb-2">
138
+ Drop files here or click to browse
139
+ </p>
140
+ <p className="text-sm text-gray-400">
141
+ Support for PNG, JPG, GIF, WebP, MP4, MOV
142
+ </p>
143
+ <p className="text-xs text-gray-500 mt-2">
144
+ Max {maxSize / 1024 / 1024}MB per file • Up to {maxFiles} files
145
+ </p>
146
+
147
+ <Button className="mt-6" variant="outline">
148
+ Select Files
149
+ </Button>
150
+ </motion.div>
151
+ )}
152
+ </AnimatePresence>
153
+ </div>
154
+
155
+ {isProcessing && (
156
+ <div className="absolute inset-0 bg-gray-900/80 rounded-xl flex items-center justify-center">
157
+ <div className="text-center">
158
+ <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mb-4"></div>
159
+ <p className="text-white">Processing...</p>
160
+ </div>
161
+ </div>
162
+ )}
163
+ </div>
164
+
165
+ {/* File List */}
166
+ {files.length > 0 && (
167
+ <div className="mt-4 space-y-2">
168
+ <p className="text-sm font-medium text-gray-300 mb-2">
169
+ Selected files ({files.length})
170
+ </p>
171
+ {files.map((file, index) => (
172
+ <motion.div
173
+ key={`${file.name}-${index}`}
174
+ initial={{ opacity: 0, x: -20 }}
175
+ animate={{ opacity: 1, x: 0 }}
176
+ transition={{ delay: index * 0.05 }}
177
+ className="flex items-center gap-3 p-3 bg-gray-800 rounded-lg"
178
+ >
179
+ {getFileIcon(file)}
180
+ <div className="flex-1 min-w-0">
181
+ <p className="text-sm font-medium text-white truncate">
182
+ {file.name}
183
+ </p>
184
+ <p className="text-xs text-gray-400">
185
+ {(file.size / 1024 / 1024).toFixed(2)} MB
186
+ </p>
187
+ </div>
188
+ <Button
189
+ size="sm"
190
+ variant="ghost"
191
+ onClick={() => removeFile(index)}
192
+ className="text-gray-400 hover:text-red-400"
193
+ >
194
+ <X className="w-4 h-4" />
195
+ </Button>
196
+ </motion.div>
197
+ ))}
198
+ </div>
199
+ )}
200
+ </div>
201
+ )
202
+ }