@moontra/moonui-pro 2.8.15 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +78 -8
- package/dist/index.mjs +861 -211
- package/package.json +1 -1
- package/src/components/file-upload/index.tsx +1184 -287
|
@@ -1,363 +1,1260 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import React from 'react'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { MoonUIBadgePro as Badge } from '../ui/badge'
|
|
3
|
+
import React, { useCallback, useState, useRef } from 'react'
|
|
4
|
+
import { motion, AnimatePresence } from 'framer-motion'
|
|
5
|
+
import { cva } from 'class-variance-authority'
|
|
7
6
|
import {
|
|
8
7
|
Upload,
|
|
8
|
+
X,
|
|
9
9
|
File,
|
|
10
|
-
Image,
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
Image as ImageIcon,
|
|
11
|
+
Video,
|
|
12
|
+
Music,
|
|
13
13
|
FileText,
|
|
14
|
-
Video,
|
|
15
|
-
Music,
|
|
16
|
-
Archive,
|
|
17
14
|
AlertCircle,
|
|
18
|
-
CheckCircle2,
|
|
19
15
|
Loader2,
|
|
16
|
+
Play,
|
|
17
|
+
Pause,
|
|
18
|
+
RotateCcw,
|
|
19
|
+
Download,
|
|
20
|
+
Eye,
|
|
21
|
+
Trash2,
|
|
22
|
+
MoreHorizontal,
|
|
23
|
+
Copy,
|
|
24
|
+
Share,
|
|
25
|
+
Archive,
|
|
20
26
|
Lock,
|
|
21
|
-
Sparkles
|
|
27
|
+
Sparkles,
|
|
28
|
+
CheckCircle2
|
|
22
29
|
} from 'lucide-react'
|
|
23
30
|
import { cn } from '../../lib/utils'
|
|
31
|
+
import { Button } from '../ui/button'
|
|
32
|
+
import { Badge } from '../ui/badge'
|
|
33
|
+
import { Progress } from '../ui/progress'
|
|
34
|
+
import { Card, CardContent } from '../ui/card'
|
|
35
|
+
import {
|
|
36
|
+
DropdownMenu,
|
|
37
|
+
DropdownMenuContent,
|
|
38
|
+
DropdownMenuItem,
|
|
39
|
+
DropdownMenuTrigger
|
|
40
|
+
} from '../ui/dropdown-menu'
|
|
41
|
+
import {
|
|
42
|
+
Dialog,
|
|
43
|
+
DialogContent,
|
|
44
|
+
DialogHeader,
|
|
45
|
+
DialogTitle
|
|
46
|
+
} from '../ui/dialog'
|
|
24
47
|
import { useSubscription } from '../../hooks/use-subscription'
|
|
25
48
|
|
|
26
|
-
interface
|
|
49
|
+
// Ana interface'ler ve type'lar
|
|
50
|
+
export interface FileUploadProProps {
|
|
27
51
|
accept?: string
|
|
28
52
|
multiple?: boolean
|
|
29
|
-
maxSize?: number //
|
|
53
|
+
maxSize?: number // bytes cinsinden
|
|
30
54
|
maxFiles?: number
|
|
31
|
-
onUpload?: (files: File[]) => Promise<void>
|
|
32
|
-
onRemove?: (file: File) => void
|
|
33
|
-
className?: string
|
|
34
55
|
disabled?: boolean
|
|
56
|
+
className?: string
|
|
57
|
+
variant?: 'default' | 'compact' | 'grid'
|
|
58
|
+
theme?: 'light' | 'dark' | 'auto'
|
|
59
|
+
|
|
60
|
+
// Gelişmiş özellikler
|
|
61
|
+
chunkSize?: number // chunk upload için
|
|
62
|
+
resumable?: boolean
|
|
63
|
+
compression?: boolean
|
|
64
|
+
imageResize?: {
|
|
65
|
+
maxWidth: number
|
|
66
|
+
maxHeight: number
|
|
67
|
+
quality: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Validasyon
|
|
71
|
+
allowedMimeTypes?: string[]
|
|
72
|
+
maxTotalSize?: number
|
|
73
|
+
customValidation?: (file: File) => Promise<string | null>
|
|
74
|
+
duplicateCheck?: boolean
|
|
75
|
+
|
|
76
|
+
// Upload ayarları
|
|
77
|
+
uploadStrategy?: 'direct' | 'presigned' | 'multipart'
|
|
78
|
+
endpoint?: string
|
|
79
|
+
headers?: Record<string, string>
|
|
80
|
+
|
|
81
|
+
// Callbacks
|
|
82
|
+
onUpload?: (files: FileUploadItem[]) => Promise<void>
|
|
83
|
+
onProgress?: (fileId: string, progress: number) => void
|
|
84
|
+
onComplete?: (fileId: string, result: any) => void
|
|
85
|
+
onError?: (fileId: string, error: string) => void
|
|
86
|
+
onRemove?: (fileId: string) => void
|
|
87
|
+
onPreview?: (file: FileUploadItem) => void
|
|
88
|
+
onBulkSelect?: (selectedIds: string[]) => void
|
|
89
|
+
|
|
90
|
+
// UI özelleştirme
|
|
35
91
|
showPreview?: boolean
|
|
36
|
-
|
|
92
|
+
showProgress?: boolean
|
|
93
|
+
showMetadata?: boolean
|
|
94
|
+
allowBulkOperations?: boolean
|
|
95
|
+
previewTypes?: ('image' | 'video' | 'audio' | 'pdf' | 'document')[]
|
|
37
96
|
}
|
|
38
97
|
|
|
39
|
-
interface
|
|
40
|
-
file: File
|
|
98
|
+
export interface FileUploadItem {
|
|
41
99
|
id: string
|
|
42
|
-
|
|
100
|
+
file: File
|
|
101
|
+
status: 'pending' | 'uploading' | 'paused' | 'success' | 'error' | 'cancelled'
|
|
43
102
|
progress: number
|
|
103
|
+
uploadedBytes?: number
|
|
104
|
+
totalBytes: number
|
|
105
|
+
speed?: number // bytes/second
|
|
106
|
+
estimatedTime?: number // seconds
|
|
44
107
|
error?: string
|
|
108
|
+
chunks?: FileChunk[]
|
|
109
|
+
preview?: FilePreview
|
|
110
|
+
metadata?: FileMetadata
|
|
111
|
+
result?: any // upload sonucu
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface FileChunk {
|
|
115
|
+
index: number
|
|
116
|
+
start: number
|
|
117
|
+
end: number
|
|
118
|
+
status: 'pending' | 'uploading' | 'success' | 'error'
|
|
119
|
+
attempts: number
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface FilePreview {
|
|
123
|
+
type: 'image' | 'video' | 'audio' | 'pdf' | 'document' | 'unknown'
|
|
45
124
|
url?: string
|
|
125
|
+
thumbnail?: string
|
|
126
|
+
duration?: number // video/audio için
|
|
127
|
+
dimensions?: { width: number; height: number }
|
|
128
|
+
pages?: number // PDF için
|
|
46
129
|
}
|
|
47
130
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
131
|
+
interface FileMetadata {
|
|
132
|
+
name: string
|
|
133
|
+
size: number
|
|
134
|
+
type: string
|
|
135
|
+
lastModified: number
|
|
136
|
+
hash?: string
|
|
137
|
+
dimensions?: { width: number; height: number }
|
|
138
|
+
duration?: number
|
|
139
|
+
bitrate?: number
|
|
55
140
|
}
|
|
56
141
|
|
|
142
|
+
// CVA variants
|
|
143
|
+
const fileUploadVariants = cva(
|
|
144
|
+
"relative overflow-hidden transition-all duration-200",
|
|
145
|
+
{
|
|
146
|
+
variants: {
|
|
147
|
+
variant: {
|
|
148
|
+
default: "border-2 border-dashed rounded-lg p-8",
|
|
149
|
+
compact: "border border-dashed rounded-md p-4",
|
|
150
|
+
grid: "border-2 border-dashed rounded-lg p-6"
|
|
151
|
+
},
|
|
152
|
+
state: {
|
|
153
|
+
idle: "border-muted-foreground/25 hover:border-primary/50",
|
|
154
|
+
dragover: "border-primary bg-primary/5 scale-102",
|
|
155
|
+
disabled: "opacity-50 cursor-not-allowed"
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
defaultVariants: {
|
|
159
|
+
variant: "default",
|
|
160
|
+
state: "idle"
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
// Utility fonksiyonlar
|
|
57
166
|
const formatFileSize = (bytes: number): string => {
|
|
58
|
-
if (bytes === 0) return '0
|
|
167
|
+
if (bytes === 0) return '0 B'
|
|
59
168
|
const k = 1024
|
|
60
|
-
const sizes = ['
|
|
169
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
61
170
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
62
171
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
|
63
172
|
}
|
|
64
173
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
showPreview = true,
|
|
75
|
-
allowedTypes = []
|
|
76
|
-
}: FileUploadProps) {
|
|
77
|
-
// Check if we're in docs mode or have pro access
|
|
78
|
-
const { hasProAccess, isLoading } = useSubscription()
|
|
79
|
-
|
|
80
|
-
// In docs mode, always show the component
|
|
174
|
+
const formatTime = (seconds: number): string => {
|
|
175
|
+
if (seconds < 60) return `${Math.round(seconds)}s`
|
|
176
|
+
const minutes = Math.floor(seconds / 60)
|
|
177
|
+
const remainingSeconds = Math.round(seconds % 60)
|
|
178
|
+
return `${minutes}m ${remainingSeconds}s`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const getFileIcon = (type: string, size: 'sm' | 'md' | 'lg' = 'md') => {
|
|
182
|
+
const iconSize = size === 'sm' ? 'h-4 w-4' : size === 'md' ? 'h-5 w-5' : 'h-6 w-6'
|
|
81
183
|
|
|
82
|
-
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
<div className="rounded-full bg-purple-100 dark:bg-purple-900/30 p-3 w-fit mx-auto">
|
|
89
|
-
<Lock className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
|
90
|
-
</div>
|
|
91
|
-
<div>
|
|
92
|
-
<h3 className="font-semibold text-lg mb-2">Pro Feature</h3>
|
|
93
|
-
<p className="text-muted-foreground text-sm mb-4">
|
|
94
|
-
File Upload is available exclusively to MoonUI Pro subscribers.
|
|
95
|
-
</p>
|
|
96
|
-
<div className="flex gap-3 justify-center">
|
|
97
|
-
<a href="/pricing">
|
|
98
|
-
<Button size="sm">
|
|
99
|
-
<Sparkles className="mr-2 h-4 w-4" />
|
|
100
|
-
Upgrade to Pro
|
|
101
|
-
</Button>
|
|
102
|
-
</a>
|
|
103
|
-
</div>
|
|
104
|
-
</div>
|
|
105
|
-
</div>
|
|
106
|
-
</CardContent>
|
|
107
|
-
</Card>
|
|
108
|
-
)
|
|
184
|
+
if (type.startsWith('image/')) return <ImageIcon className={iconSize} />
|
|
185
|
+
if (type.startsWith('video/')) return <Video className={iconSize} />
|
|
186
|
+
if (type.startsWith('audio/')) return <Music className={iconSize} />
|
|
187
|
+
if (type.includes('pdf')) return <FileText className={iconSize} />
|
|
188
|
+
if (type.includes('zip') || type.includes('rar') || type.includes('7z')) {
|
|
189
|
+
return <Archive className={iconSize} />
|
|
109
190
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const [isDragOver, setIsDragOver] = React.useState(false)
|
|
113
|
-
const [isUploading, setIsUploading] = React.useState(false)
|
|
114
|
-
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
|
191
|
+
return <File className={iconSize} />
|
|
192
|
+
}
|
|
115
193
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
194
|
+
const generateFileHash = async (file: File): Promise<string> => {
|
|
195
|
+
const buffer = await file.arrayBuffer()
|
|
196
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
|
197
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
198
|
+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
|
199
|
+
}
|
|
121
200
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
201
|
+
const createImagePreview = (file: File): Promise<FilePreview> => {
|
|
202
|
+
return new Promise((resolve) => {
|
|
203
|
+
const img = new Image()
|
|
204
|
+
const url = URL.createObjectURL(file)
|
|
205
|
+
|
|
206
|
+
img.onload = () => {
|
|
207
|
+
resolve({
|
|
208
|
+
type: 'image',
|
|
209
|
+
url,
|
|
210
|
+
thumbnail: url,
|
|
211
|
+
dimensions: { width: img.width, height: img.height }
|
|
212
|
+
})
|
|
125
213
|
}
|
|
126
|
-
|
|
127
|
-
return null
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const handleFileSelect = async (selectedFiles: FileList | null) => {
|
|
131
|
-
if (!selectedFiles || disabled) return
|
|
132
|
-
|
|
133
|
-
const fileArray = Array.from(selectedFiles)
|
|
134
214
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
215
|
+
img.onerror = () => {
|
|
216
|
+
resolve({
|
|
217
|
+
type: 'image',
|
|
218
|
+
url
|
|
219
|
+
})
|
|
139
220
|
}
|
|
221
|
+
|
|
222
|
+
img.src = url
|
|
223
|
+
})
|
|
224
|
+
}
|
|
140
225
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
226
|
+
const createVideoPreview = (file: File): Promise<FilePreview> => {
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
const video = document.createElement('video')
|
|
229
|
+
const url = URL.createObjectURL(file)
|
|
230
|
+
|
|
231
|
+
video.onloadedmetadata = () => {
|
|
232
|
+
// Video thumbnail oluşturma
|
|
233
|
+
const canvas = document.createElement('canvas')
|
|
234
|
+
const ctx = canvas.getContext('2d')!
|
|
235
|
+
|
|
236
|
+
canvas.width = 320
|
|
237
|
+
canvas.height = (video.videoHeight / video.videoWidth) * 320
|
|
238
|
+
|
|
239
|
+
video.currentTime = Math.min(1, video.duration / 4) // İlk saniye veya %25
|
|
240
|
+
|
|
241
|
+
video.onseeked = () => {
|
|
242
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
|
243
|
+
const thumbnail = canvas.toDataURL('image/jpeg', 0.8)
|
|
244
|
+
|
|
245
|
+
resolve({
|
|
246
|
+
type: 'video',
|
|
247
|
+
url,
|
|
248
|
+
thumbnail,
|
|
249
|
+
duration: video.duration,
|
|
250
|
+
dimensions: { width: video.videoWidth, height: video.videoHeight }
|
|
154
251
|
})
|
|
155
252
|
}
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
if (invalidFiles.length > 0) {
|
|
159
|
-
alert(invalidFiles.join('\n'))
|
|
160
253
|
}
|
|
254
|
+
|
|
255
|
+
video.onerror = () => {
|
|
256
|
+
resolve({
|
|
257
|
+
type: 'video',
|
|
258
|
+
url
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
video.src = url
|
|
263
|
+
})
|
|
264
|
+
}
|
|
161
265
|
|
|
162
|
-
|
|
266
|
+
// Bulk operation bileşeni
|
|
267
|
+
const BulkActions = ({
|
|
268
|
+
selectedIds,
|
|
269
|
+
onClearSelection,
|
|
270
|
+
onBulkRemove,
|
|
271
|
+
onBulkDownload
|
|
272
|
+
}: {
|
|
273
|
+
selectedIds: string[]
|
|
274
|
+
onClearSelection: () => void
|
|
275
|
+
onBulkRemove: () => void
|
|
276
|
+
onBulkDownload: () => void
|
|
277
|
+
}) => {
|
|
278
|
+
if (selectedIds.length === 0) return null
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<motion.div
|
|
282
|
+
initial={{ opacity: 0, y: -10 }}
|
|
283
|
+
animate={{ opacity: 1, y: 0 }}
|
|
284
|
+
exit={{ opacity: 0, y: -10 }}
|
|
285
|
+
className="flex items-center gap-2 p-3 bg-primary/5 rounded-lg border"
|
|
286
|
+
>
|
|
287
|
+
<span className="text-sm font-medium">{selectedIds.length} dosya seçildi</span>
|
|
288
|
+
<div className="flex gap-1 ml-auto">
|
|
289
|
+
<Button variant="ghost" size="sm" onClick={onBulkDownload}>
|
|
290
|
+
<Download className="h-4 w-4 mr-1" />
|
|
291
|
+
İndir
|
|
292
|
+
</Button>
|
|
293
|
+
<Button variant="ghost" size="sm" onClick={onBulkRemove}>
|
|
294
|
+
<Trash2 className="h-4 w-4 mr-1" />
|
|
295
|
+
Sil
|
|
296
|
+
</Button>
|
|
297
|
+
<Button variant="ghost" size="sm" onClick={onClearSelection}>
|
|
298
|
+
<X className="h-4 w-4" />
|
|
299
|
+
</Button>
|
|
300
|
+
</div>
|
|
301
|
+
</motion.div>
|
|
302
|
+
)
|
|
303
|
+
}
|
|
163
304
|
|
|
164
|
-
|
|
165
|
-
|
|
305
|
+
// Dosya önizleme modal
|
|
306
|
+
const FilePreviewModal = ({
|
|
307
|
+
file,
|
|
308
|
+
isOpen,
|
|
309
|
+
onClose
|
|
310
|
+
}: {
|
|
311
|
+
file: FileUploadItem | null
|
|
312
|
+
isOpen: boolean
|
|
313
|
+
onClose: () => void
|
|
314
|
+
}) => {
|
|
315
|
+
if (!file || !file.preview) return null
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
319
|
+
<DialogContent className="max-w-4xl max-h-[90vh]">
|
|
320
|
+
<DialogHeader>
|
|
321
|
+
<DialogTitle className="flex items-center gap-2">
|
|
322
|
+
{getFileIcon(file.file.type)}
|
|
323
|
+
{file.file.name}
|
|
324
|
+
<Badge variant="secondary">
|
|
325
|
+
{formatFileSize(file.file.size)}
|
|
326
|
+
</Badge>
|
|
327
|
+
</DialogTitle>
|
|
328
|
+
</DialogHeader>
|
|
329
|
+
|
|
330
|
+
<div className="flex items-center justify-center p-4 bg-muted/20 rounded-lg">
|
|
331
|
+
{file.preview.type === 'image' && (
|
|
332
|
+
<img
|
|
333
|
+
src={file.preview.url}
|
|
334
|
+
alt={file.file.name}
|
|
335
|
+
className="max-w-full max-h-[60vh] object-contain rounded"
|
|
336
|
+
/>
|
|
337
|
+
)}
|
|
338
|
+
|
|
339
|
+
{file.preview.type === 'video' && (
|
|
340
|
+
<video
|
|
341
|
+
src={file.preview.url}
|
|
342
|
+
controls
|
|
343
|
+
className="max-w-full max-h-[60vh] rounded"
|
|
344
|
+
/>
|
|
345
|
+
)}
|
|
346
|
+
|
|
347
|
+
{file.preview.type === 'audio' && (
|
|
348
|
+
<div className="w-full max-w-md">
|
|
349
|
+
<audio src={file.preview.url} controls className="w-full" />
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
|
|
353
|
+
{!['image', 'video', 'audio'].includes(file.preview.type) && (
|
|
354
|
+
<div className="text-center py-8">
|
|
355
|
+
{getFileIcon(file.file.type, 'lg')}
|
|
356
|
+
<p className="mt-2 text-muted-foreground">Önizleme mevcut değil</p>
|
|
357
|
+
</div>
|
|
358
|
+
)}
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
{file.preview.dimensions && (
|
|
362
|
+
<div className="flex gap-4 text-sm text-muted-foreground">
|
|
363
|
+
<span>Boyutlar: {file.preview.dimensions.width} × {file.preview.dimensions.height}</span>
|
|
364
|
+
{file.preview.duration && (
|
|
365
|
+
<span>Süre: {formatTime(file.preview.duration)}</span>
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
)}
|
|
369
|
+
</DialogContent>
|
|
370
|
+
</Dialog>
|
|
371
|
+
)
|
|
372
|
+
}
|
|
166
373
|
|
|
167
|
-
|
|
168
|
-
|
|
374
|
+
// Ana bileşen
|
|
375
|
+
export const MoonUIFileUploadPro = React.forwardRef<HTMLDivElement, FileUploadProProps>(
|
|
376
|
+
({
|
|
377
|
+
accept = "*",
|
|
378
|
+
multiple = true,
|
|
379
|
+
maxSize = 100 * 1024 * 1024, // 100MB
|
|
380
|
+
maxFiles = 10,
|
|
381
|
+
disabled = false,
|
|
382
|
+
className,
|
|
383
|
+
variant = "default",
|
|
384
|
+
chunkSize = 1024 * 1024, // 1MB chunks
|
|
385
|
+
resumable = true,
|
|
386
|
+
compression = false,
|
|
387
|
+
allowedMimeTypes = [],
|
|
388
|
+
maxTotalSize,
|
|
389
|
+
duplicateCheck = true,
|
|
390
|
+
uploadStrategy = 'direct',
|
|
391
|
+
showPreview = true,
|
|
392
|
+
showProgress = true,
|
|
393
|
+
showMetadata = true,
|
|
394
|
+
allowBulkOperations = true,
|
|
395
|
+
previewTypes = ['image', 'video', 'audio', 'pdf'],
|
|
396
|
+
onUpload,
|
|
397
|
+
onProgress,
|
|
398
|
+
onComplete,
|
|
399
|
+
onError,
|
|
400
|
+
onRemove,
|
|
401
|
+
onPreview,
|
|
402
|
+
onBulkSelect,
|
|
403
|
+
customValidation,
|
|
404
|
+
...props
|
|
405
|
+
}, ref) => {
|
|
406
|
+
|
|
407
|
+
// Subscription kontrolü - Pro bileşen
|
|
408
|
+
const { hasProAccess, isLoading } = useSubscription()
|
|
409
|
+
|
|
410
|
+
// State'ler
|
|
411
|
+
const [files, setFiles] = useState<FileUploadItem[]>([])
|
|
412
|
+
const [isDragOver, setIsDragOver] = useState(false)
|
|
413
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
|
414
|
+
const [previewFile, setPreviewFile] = useState<FileUploadItem | null>(null)
|
|
415
|
+
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
|
|
416
|
+
const [error, setError] = useState<string | null>(null)
|
|
417
|
+
|
|
418
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
419
|
+
const uploadQueue = useRef<Map<string, AbortController>>(new Map())
|
|
420
|
+
|
|
421
|
+
// Pro lisans kontrolü
|
|
422
|
+
if (!isLoading && !hasProAccess) {
|
|
423
|
+
return (
|
|
424
|
+
<Card className={cn("w-full", className)}>
|
|
425
|
+
<CardContent className="py-12 text-center">
|
|
426
|
+
<div className="max-w-md mx-auto space-y-4">
|
|
427
|
+
<div className="rounded-full bg-purple-100 dark:bg-purple-900/30 p-3 w-fit mx-auto">
|
|
428
|
+
<Lock className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
|
429
|
+
</div>
|
|
430
|
+
<div>
|
|
431
|
+
<h3 className="font-semibold text-lg mb-2">Pro Özellik</h3>
|
|
432
|
+
<p className="text-muted-foreground text-sm mb-4">
|
|
433
|
+
Gelişmiş Dosya Yükleme sadece MoonUI Pro abonelerine özeldir.
|
|
434
|
+
</p>
|
|
435
|
+
<div className="flex gap-3 justify-center">
|
|
436
|
+
<a href="/pricing">
|
|
437
|
+
<Button size="sm">
|
|
438
|
+
<Sparkles className="mr-2 h-4 w-4" />
|
|
439
|
+
Pro'ya Yükseltin
|
|
440
|
+
</Button>
|
|
441
|
+
</a>
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
</CardContent>
|
|
446
|
+
</Card>
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Dosya validasyonu
|
|
451
|
+
const validateFile = useCallback(async (file: File): Promise<string | null> => {
|
|
452
|
+
// Boyut kontrolü
|
|
453
|
+
if (file.size > maxSize) {
|
|
454
|
+
return `Dosya boyutu ${formatFileSize(maxSize)} limitini aşıyor`
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// MIME type kontrolü
|
|
458
|
+
if (allowedMimeTypes.length > 0 && !allowedMimeTypes.includes(file.type)) {
|
|
459
|
+
return `Dosya türü ${file.type} desteklenmiyor`
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Accept kontrolü
|
|
463
|
+
if (accept !== "*") {
|
|
464
|
+
const acceptTypes = accept.split(',').map(t => t.trim())
|
|
465
|
+
const isAccepted = acceptTypes.some(acceptType => {
|
|
466
|
+
if (acceptType.includes('*')) {
|
|
467
|
+
return file.type.startsWith(acceptType.replace('*', ''))
|
|
468
|
+
}
|
|
469
|
+
return file.type === acceptType
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
if (!isAccepted) {
|
|
473
|
+
return `Dosya türü kabul edilmiyor`
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Özel validasyon
|
|
478
|
+
if (customValidation) {
|
|
479
|
+
const customError = await customValidation(file)
|
|
480
|
+
if (customError) return customError
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return null
|
|
484
|
+
}, [maxSize, allowedMimeTypes, accept, customValidation])
|
|
485
|
+
|
|
486
|
+
// Duplicate kontrolü
|
|
487
|
+
const checkDuplicate = useCallback(async (file: File): Promise<boolean> => {
|
|
488
|
+
if (!duplicateCheck) return false
|
|
489
|
+
|
|
490
|
+
const hash = await generateFileHash(file)
|
|
491
|
+
return files.some(f => f.metadata?.hash === hash)
|
|
492
|
+
}, [files, duplicateCheck])
|
|
493
|
+
|
|
494
|
+
// Dosya önizleme oluşturma
|
|
495
|
+
const createPreview = useCallback(async (file: File): Promise<FilePreview | undefined> => {
|
|
496
|
+
if (!showPreview) return undefined
|
|
497
|
+
|
|
498
|
+
const fileType = file.type.split('/')[0] as 'image' | 'video' | 'audio'
|
|
499
|
+
|
|
500
|
+
if (!previewTypes.includes(fileType)) return undefined
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
if (fileType === 'image') {
|
|
504
|
+
return await createImagePreview(file)
|
|
505
|
+
} else if (fileType === 'video') {
|
|
506
|
+
return await createVideoPreview(file)
|
|
507
|
+
} else if (fileType === 'audio') {
|
|
508
|
+
const url = URL.createObjectURL(file)
|
|
509
|
+
return {
|
|
510
|
+
type: 'audio',
|
|
511
|
+
url
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
} catch (error) {
|
|
515
|
+
console.warn('Önizleme oluşturulamadı:', error)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return undefined
|
|
519
|
+
}, [showPreview, previewTypes])
|
|
520
|
+
|
|
521
|
+
// Dosya metadata oluşturma
|
|
522
|
+
const createMetadata = useCallback(async (file: File): Promise<FileMetadata> => {
|
|
523
|
+
const metadata: FileMetadata = {
|
|
524
|
+
name: file.name,
|
|
525
|
+
size: file.size,
|
|
526
|
+
type: file.type,
|
|
527
|
+
lastModified: file.lastModified
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (duplicateCheck) {
|
|
531
|
+
metadata.hash = await generateFileHash(file)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return metadata
|
|
535
|
+
}, [duplicateCheck])
|
|
536
|
+
|
|
537
|
+
// Chunked upload simülasyonu
|
|
538
|
+
const uploadFileChunked = useCallback(async (fileItem: FileUploadItem) => {
|
|
539
|
+
const { file } = fileItem
|
|
540
|
+
const chunks: FileChunk[] = []
|
|
541
|
+
const chunkCount = Math.ceil(file.size / chunkSize)
|
|
542
|
+
|
|
543
|
+
// Chunk'ları oluştur
|
|
544
|
+
for (let i = 0; i < chunkCount; i++) {
|
|
545
|
+
chunks.push({
|
|
546
|
+
index: i,
|
|
547
|
+
start: i * chunkSize,
|
|
548
|
+
end: Math.min((i + 1) * chunkSize, file.size),
|
|
549
|
+
status: 'pending',
|
|
550
|
+
attempts: 0
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// FileItem'i güncelle
|
|
555
|
+
setFiles(prev => prev.map(f =>
|
|
556
|
+
f.id === fileItem.id
|
|
557
|
+
? { ...f, chunks, status: 'uploading' }
|
|
558
|
+
: f
|
|
559
|
+
))
|
|
560
|
+
|
|
561
|
+
const abortController = new AbortController()
|
|
562
|
+
uploadQueue.current.set(fileItem.id, abortController)
|
|
563
|
+
|
|
564
|
+
let uploadedBytes = 0
|
|
565
|
+
const startTime = Date.now()
|
|
566
|
+
|
|
169
567
|
try {
|
|
170
|
-
|
|
568
|
+
// Chunk'ları sırayla yükle
|
|
569
|
+
for (const chunk of chunks) {
|
|
570
|
+
if (abortController.signal.aborted) {
|
|
571
|
+
throw new Error('Upload cancelled')
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Chunk upload simülasyonu
|
|
575
|
+
await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 200))
|
|
576
|
+
|
|
577
|
+
uploadedBytes += (chunk.end - chunk.start)
|
|
578
|
+
const progress = Math.round((uploadedBytes / file.size) * 100)
|
|
579
|
+
const elapsedTime = (Date.now() - startTime) / 1000
|
|
580
|
+
const speed = uploadedBytes / elapsedTime
|
|
581
|
+
const estimatedTime = (file.size - uploadedBytes) / speed
|
|
582
|
+
|
|
583
|
+
// Progress güncelle
|
|
584
|
+
setFiles(prev => prev.map(f =>
|
|
585
|
+
f.id === fileItem.id
|
|
586
|
+
? {
|
|
587
|
+
...f,
|
|
588
|
+
progress,
|
|
589
|
+
uploadedBytes,
|
|
590
|
+
speed,
|
|
591
|
+
estimatedTime: estimatedTime || 0,
|
|
592
|
+
chunks: f.chunks?.map(c =>
|
|
593
|
+
c.index === chunk.index
|
|
594
|
+
? { ...c, status: 'success' }
|
|
595
|
+
: c
|
|
596
|
+
)
|
|
597
|
+
}
|
|
598
|
+
: f
|
|
599
|
+
))
|
|
600
|
+
|
|
601
|
+
onProgress?.(fileItem.id, progress)
|
|
602
|
+
}
|
|
171
603
|
|
|
172
|
-
//
|
|
604
|
+
// Upload tamamlandı
|
|
173
605
|
setFiles(prev => prev.map(f =>
|
|
174
|
-
|
|
175
|
-
? { ...f, status: 'success'
|
|
606
|
+
f.id === fileItem.id
|
|
607
|
+
? { ...f, status: 'success', progress: 100 }
|
|
176
608
|
: f
|
|
177
609
|
))
|
|
610
|
+
|
|
611
|
+
onComplete?.(fileItem.id, { success: true })
|
|
612
|
+
|
|
178
613
|
} catch (error) {
|
|
179
|
-
// Update all files to error
|
|
180
614
|
setFiles(prev => prev.map(f =>
|
|
181
|
-
|
|
182
|
-
? { ...f, status: 'error'
|
|
615
|
+
f.id === fileItem.id
|
|
616
|
+
? { ...f, status: 'error', error: error instanceof Error ? error.message : 'Upload failed' }
|
|
183
617
|
: f
|
|
184
618
|
))
|
|
619
|
+
|
|
620
|
+
onError?.(fileItem.id, error instanceof Error ? error.message : 'Upload failed')
|
|
621
|
+
} finally {
|
|
622
|
+
uploadQueue.current.delete(fileItem.id)
|
|
185
623
|
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
setTimeout(() => {
|
|
198
|
-
clearInterval(interval)
|
|
199
|
-
setFiles(prev => prev.map(f =>
|
|
200
|
-
f.id === validFile.id
|
|
201
|
-
? { ...f, status: 'success' as const, progress: 100 }
|
|
202
|
-
: f
|
|
203
|
-
))
|
|
204
|
-
}, 1000)
|
|
624
|
+
}, [chunkSize, onProgress, onComplete, onError])
|
|
625
|
+
|
|
626
|
+
// Dosya işleme
|
|
627
|
+
const processFiles = useCallback(async (fileList: FileList | File[]) => {
|
|
628
|
+
const fileArray = Array.from(fileList)
|
|
629
|
+
setError(null)
|
|
630
|
+
|
|
631
|
+
// Toplam dosya sayısı kontrolü
|
|
632
|
+
if (files.length + fileArray.length > maxFiles) {
|
|
633
|
+
setError(`Maksimum ${maxFiles} dosya yükleyebilirsiniz`)
|
|
634
|
+
return
|
|
205
635
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
{
|
|
253
|
-
|
|
636
|
+
|
|
637
|
+
// Toplam boyut kontrolü
|
|
638
|
+
if (maxTotalSize) {
|
|
639
|
+
const currentSize = files.reduce((sum, f) => sum + f.file.size, 0)
|
|
640
|
+
const newSize = fileArray.reduce((sum, f) => sum + f.size, 0)
|
|
641
|
+
|
|
642
|
+
if (currentSize + newSize > maxTotalSize) {
|
|
643
|
+
setError(`Toplam dosya boyutu ${formatFileSize(maxTotalSize)} limitini aşıyor`)
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const validFiles: File[] = []
|
|
649
|
+
const errors: string[] = []
|
|
650
|
+
|
|
651
|
+
// Her dosyayı validate et
|
|
652
|
+
for (const file of fileArray) {
|
|
653
|
+
const validationError = await validateFile(file)
|
|
654
|
+
if (validationError) {
|
|
655
|
+
errors.push(`${file.name}: ${validationError}`)
|
|
656
|
+
continue
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const isDuplicate = await checkDuplicate(file)
|
|
660
|
+
if (isDuplicate) {
|
|
661
|
+
errors.push(`${file.name}: Dosya zaten mevcut`)
|
|
662
|
+
continue
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
validFiles.push(file)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (errors.length > 0) {
|
|
669
|
+
setError(errors.join(', '))
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (validFiles.length === 0) return
|
|
673
|
+
|
|
674
|
+
// FileUploadItem'ları oluştur
|
|
675
|
+
const newFileItems: FileUploadItem[] = []
|
|
676
|
+
|
|
677
|
+
for (const file of validFiles) {
|
|
678
|
+
const id = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
|
|
679
|
+
const preview = await createPreview(file)
|
|
680
|
+
const metadata = await createMetadata(file)
|
|
681
|
+
|
|
682
|
+
newFileItems.push({
|
|
683
|
+
id,
|
|
684
|
+
file,
|
|
685
|
+
status: 'pending',
|
|
686
|
+
progress: 0,
|
|
687
|
+
totalBytes: file.size,
|
|
688
|
+
preview,
|
|
689
|
+
metadata
|
|
690
|
+
})
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
setFiles(prev => [...prev, ...newFileItems])
|
|
694
|
+
|
|
695
|
+
// Upload işlemini başlat
|
|
696
|
+
if (onUpload) {
|
|
697
|
+
try {
|
|
698
|
+
await onUpload(newFileItems)
|
|
699
|
+
} catch (error) {
|
|
700
|
+
console.error('Upload failed:', error)
|
|
701
|
+
}
|
|
702
|
+
} else {
|
|
703
|
+
// Otomatik upload başlat
|
|
704
|
+
newFileItems.forEach(fileItem => {
|
|
705
|
+
uploadFileChunked(fileItem)
|
|
706
|
+
})
|
|
707
|
+
}
|
|
708
|
+
}, [
|
|
709
|
+
files,
|
|
710
|
+
maxFiles,
|
|
711
|
+
maxTotalSize,
|
|
712
|
+
validateFile,
|
|
713
|
+
checkDuplicate,
|
|
714
|
+
createPreview,
|
|
715
|
+
createMetadata,
|
|
716
|
+
onUpload,
|
|
717
|
+
uploadFileChunked
|
|
718
|
+
])
|
|
719
|
+
|
|
720
|
+
// Drag & Drop handlers
|
|
721
|
+
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
722
|
+
e.preventDefault()
|
|
723
|
+
setIsDragOver(false)
|
|
724
|
+
|
|
725
|
+
if (disabled) return
|
|
726
|
+
|
|
727
|
+
const droppedFiles = Array.from(e.dataTransfer.files)
|
|
728
|
+
if (droppedFiles.length > 0) {
|
|
729
|
+
processFiles(droppedFiles)
|
|
730
|
+
}
|
|
731
|
+
}, [processFiles, disabled])
|
|
732
|
+
|
|
733
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
734
|
+
e.preventDefault()
|
|
735
|
+
if (!disabled) {
|
|
736
|
+
setIsDragOver(true)
|
|
737
|
+
}
|
|
738
|
+
}, [disabled])
|
|
739
|
+
|
|
740
|
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
741
|
+
e.preventDefault()
|
|
742
|
+
setIsDragOver(false)
|
|
743
|
+
}, [])
|
|
744
|
+
|
|
745
|
+
// Dosya seçimi
|
|
746
|
+
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
747
|
+
const selectedFiles = e.target.files
|
|
748
|
+
if (selectedFiles && selectedFiles.length > 0) {
|
|
749
|
+
processFiles(selectedFiles)
|
|
750
|
+
}
|
|
751
|
+
// Input'u temizle
|
|
752
|
+
e.target.value = ''
|
|
753
|
+
}, [processFiles])
|
|
754
|
+
|
|
755
|
+
// Dosya kaldırma
|
|
756
|
+
const removeFile = useCallback((fileId: string) => {
|
|
757
|
+
const fileToRemove = files.find(f => f.id === fileId)
|
|
758
|
+
if (!fileToRemove) return
|
|
759
|
+
|
|
760
|
+
// Upload'ı iptal et
|
|
761
|
+
const controller = uploadQueue.current.get(fileId)
|
|
762
|
+
if (controller) {
|
|
763
|
+
controller.abort()
|
|
764
|
+
uploadQueue.current.delete(fileId)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Preview URL'yi temizle
|
|
768
|
+
if (fileToRemove.preview?.url) {
|
|
769
|
+
URL.revokeObjectURL(fileToRemove.preview.url)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
setFiles(prev => prev.filter(f => f.id !== fileId))
|
|
773
|
+
setSelectedIds(prev => prev.filter(id => id !== fileId))
|
|
774
|
+
|
|
775
|
+
onRemove?.(fileId)
|
|
776
|
+
}, [files, onRemove])
|
|
777
|
+
|
|
778
|
+
// Upload'ı duraklat/devam ettir
|
|
779
|
+
const pauseResumeUpload = useCallback((fileId: string) => {
|
|
780
|
+
const file = files.find(f => f.id === fileId)
|
|
781
|
+
if (!file) return
|
|
782
|
+
|
|
783
|
+
if (file.status === 'uploading') {
|
|
784
|
+
const controller = uploadQueue.current.get(fileId)
|
|
785
|
+
if (controller) {
|
|
786
|
+
controller.abort()
|
|
787
|
+
uploadQueue.current.delete(fileId)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
setFiles(prev => prev.map(f =>
|
|
791
|
+
f.id === fileId ? { ...f, status: 'paused' } : f
|
|
792
|
+
))
|
|
793
|
+
} else if (file.status === 'paused' && resumable) {
|
|
794
|
+
uploadFileChunked(file)
|
|
795
|
+
}
|
|
796
|
+
}, [files, resumable, uploadFileChunked])
|
|
797
|
+
|
|
798
|
+
// Bulk operations
|
|
799
|
+
const handleBulkSelect = useCallback((fileId: string, selected: boolean) => {
|
|
800
|
+
setSelectedIds(prev =>
|
|
801
|
+
selected
|
|
802
|
+
? [...prev, fileId]
|
|
803
|
+
: prev.filter(id => id !== fileId)
|
|
804
|
+
)
|
|
805
|
+
}, [])
|
|
806
|
+
|
|
807
|
+
const handleSelectAll = useCallback(() => {
|
|
808
|
+
const allIds = files.map(f => f.id)
|
|
809
|
+
setSelectedIds(allIds)
|
|
810
|
+
onBulkSelect?.(allIds)
|
|
811
|
+
}, [files, onBulkSelect])
|
|
812
|
+
|
|
813
|
+
const handleClearSelection = useCallback(() => {
|
|
814
|
+
setSelectedIds([])
|
|
815
|
+
onBulkSelect?.([])
|
|
816
|
+
}, [onBulkSelect])
|
|
817
|
+
|
|
818
|
+
const handleBulkRemove = useCallback(() => {
|
|
819
|
+
selectedIds.forEach(id => removeFile(id))
|
|
820
|
+
setSelectedIds([])
|
|
821
|
+
}, [selectedIds, removeFile])
|
|
822
|
+
|
|
823
|
+
const handleBulkDownload = useCallback(() => {
|
|
824
|
+
// Bulk download implementation
|
|
825
|
+
console.log('Bulk download:', selectedIds)
|
|
826
|
+
}, [selectedIds])
|
|
827
|
+
|
|
828
|
+
// Preview
|
|
829
|
+
const handlePreview = useCallback((file: FileUploadItem) => {
|
|
830
|
+
setPreviewFile(file)
|
|
831
|
+
setIsPreviewOpen(true)
|
|
832
|
+
onPreview?.(file)
|
|
833
|
+
}, [onPreview])
|
|
834
|
+
|
|
835
|
+
return (
|
|
836
|
+
<div ref={ref} className={cn("w-full space-y-4", className)} {...props}>
|
|
837
|
+
{/* Bulk Actions */}
|
|
838
|
+
<AnimatePresence>
|
|
839
|
+
{allowBulkOperations && selectedIds.length > 0 && (
|
|
840
|
+
<BulkActions
|
|
841
|
+
selectedIds={selectedIds}
|
|
842
|
+
onClearSelection={handleClearSelection}
|
|
843
|
+
onBulkRemove={handleBulkRemove}
|
|
844
|
+
onBulkDownload={handleBulkDownload}
|
|
845
|
+
/>
|
|
846
|
+
)}
|
|
847
|
+
</AnimatePresence>
|
|
848
|
+
|
|
849
|
+
{/* Upload Area */}
|
|
850
|
+
<motion.div
|
|
254
851
|
className={cn(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
852
|
+
fileUploadVariants({
|
|
853
|
+
variant,
|
|
854
|
+
state: disabled ? "disabled" : isDragOver ? "dragover" : "idle"
|
|
855
|
+
})
|
|
258
856
|
)}
|
|
259
857
|
onDrop={handleDrop}
|
|
260
858
|
onDragOver={handleDragOver}
|
|
261
859
|
onDragLeave={handleDragLeave}
|
|
262
|
-
onClick={
|
|
860
|
+
onClick={() => !disabled && fileInputRef.current?.click()}
|
|
861
|
+
animate={{
|
|
862
|
+
scale: isDragOver ? 1.02 : 1
|
|
863
|
+
}}
|
|
864
|
+
transition={{ duration: 0.2 }}
|
|
263
865
|
>
|
|
264
|
-
<
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
{
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
866
|
+
<input
|
|
867
|
+
ref={fileInputRef}
|
|
868
|
+
type="file"
|
|
869
|
+
accept={accept}
|
|
870
|
+
multiple={multiple}
|
|
871
|
+
disabled={disabled}
|
|
872
|
+
onChange={handleFileSelect}
|
|
873
|
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
874
|
+
/>
|
|
875
|
+
|
|
876
|
+
<div className="text-center space-y-4">
|
|
877
|
+
<motion.div
|
|
878
|
+
animate={{
|
|
879
|
+
scale: isDragOver ? 1.1 : 1,
|
|
880
|
+
rotate: isDragOver ? 5 : 0
|
|
881
|
+
}}
|
|
882
|
+
className="mx-auto h-12 w-12 text-muted-foreground"
|
|
883
|
+
>
|
|
884
|
+
<Upload className="h-full w-full" />
|
|
885
|
+
</motion.div>
|
|
886
|
+
|
|
887
|
+
<div>
|
|
888
|
+
<h3 className="text-lg font-semibold">
|
|
889
|
+
{isDragOver ? 'Dosyaları buraya bırakın' : 'Dosya Yükleyin'}
|
|
890
|
+
</h3>
|
|
891
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
892
|
+
Dosyaları sürükleyip bırakın veya tıklayarak seçin
|
|
893
|
+
</p>
|
|
894
|
+
<div className="flex items-center justify-center gap-4 mt-3 text-xs text-muted-foreground">
|
|
895
|
+
<span>Maks {maxFiles} dosya</span>
|
|
896
|
+
<span>•</span>
|
|
897
|
+
<span>{formatFileSize(maxSize)} her dosya</span>
|
|
898
|
+
{resumable && (
|
|
899
|
+
<>
|
|
900
|
+
<span>•</span>
|
|
901
|
+
<span>Devam ettirilebilir</span>
|
|
902
|
+
</>
|
|
903
|
+
)}
|
|
904
|
+
</div>
|
|
905
|
+
</div>
|
|
906
|
+
|
|
907
|
+
<Button variant="outline" disabled={disabled} type="button">
|
|
908
|
+
<Upload className="mr-2 h-4 w-4" />
|
|
909
|
+
Dosya Seç
|
|
910
|
+
</Button>
|
|
911
|
+
</div>
|
|
912
|
+
</motion.div>
|
|
913
|
+
|
|
914
|
+
{/* Error Message */}
|
|
915
|
+
<AnimatePresence>
|
|
916
|
+
{error && (
|
|
917
|
+
<motion.div
|
|
918
|
+
initial={{ opacity: 0, y: -10 }}
|
|
919
|
+
animate={{ opacity: 1, y: 0 }}
|
|
920
|
+
exit={{ opacity: 0, y: -10 }}
|
|
921
|
+
className="flex items-center gap-2 p-3 bg-destructive/10 border border-destructive/20 rounded-lg"
|
|
922
|
+
>
|
|
923
|
+
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
|
|
924
|
+
<span className="text-sm text-destructive">{error}</span>
|
|
925
|
+
<Button
|
|
926
|
+
variant="ghost"
|
|
927
|
+
size="sm"
|
|
928
|
+
className="ml-auto h-6 w-6 p-0"
|
|
929
|
+
onClick={() => setError(null)}
|
|
930
|
+
>
|
|
931
|
+
<X className="h-3 w-3" />
|
|
932
|
+
</Button>
|
|
933
|
+
</motion.div>
|
|
934
|
+
)}
|
|
935
|
+
</AnimatePresence>
|
|
936
|
+
|
|
284
937
|
{/* File List */}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
{uploadedFile.status}
|
|
302
|
-
</Badge>
|
|
303
|
-
</div>
|
|
304
|
-
|
|
305
|
-
<div className="flex items-center justify-between mt-1">
|
|
306
|
-
<p className="text-xs text-muted-foreground">
|
|
307
|
-
{formatFileSize(uploadedFile.file.size)}
|
|
308
|
-
</p>
|
|
309
|
-
{uploadedFile.status === 'uploading' && (
|
|
310
|
-
<span className="text-xs text-muted-foreground">
|
|
311
|
-
{uploadedFile.progress}%
|
|
312
|
-
</span>
|
|
313
|
-
)}
|
|
314
|
-
</div>
|
|
315
|
-
|
|
316
|
-
{uploadedFile.status === 'uploading' && (
|
|
317
|
-
<div className="mt-2 h-2 bg-secondary rounded-full overflow-hidden">
|
|
318
|
-
<div
|
|
319
|
-
className="h-full bg-primary transition-all duration-300 ease-out"
|
|
320
|
-
style={{ width: `${uploadedFile.progress}%` }}
|
|
321
|
-
/>
|
|
322
|
-
</div>
|
|
323
|
-
)}
|
|
324
|
-
|
|
325
|
-
{uploadedFile.error && (
|
|
326
|
-
<p className="text-xs text-destructive mt-1">{uploadedFile.error}</p>
|
|
327
|
-
)}
|
|
328
|
-
</div>
|
|
329
|
-
|
|
330
|
-
<div className="flex items-center gap-1">
|
|
331
|
-
{uploadedFile.status === 'success' && showPreview && uploadedFile.file.type.startsWith('image/') && (
|
|
332
|
-
<Button variant="ghost" size="sm">
|
|
333
|
-
<Download className="h-4 w-4" />
|
|
334
|
-
</Button>
|
|
335
|
-
)}
|
|
336
|
-
|
|
337
|
-
<Button
|
|
338
|
-
variant="ghost"
|
|
938
|
+
<AnimatePresence>
|
|
939
|
+
{files.length > 0 && (
|
|
940
|
+
<motion.div
|
|
941
|
+
initial={{ opacity: 0, height: 0 }}
|
|
942
|
+
animate={{ opacity: 1, height: 'auto' }}
|
|
943
|
+
exit={{ opacity: 0, height: 0 }}
|
|
944
|
+
className="space-y-3"
|
|
945
|
+
>
|
|
946
|
+
{/* List Header */}
|
|
947
|
+
<div className="flex items-center justify-between">
|
|
948
|
+
<h4 className="text-sm font-medium">
|
|
949
|
+
Yüklenen Dosyalar ({files.length})
|
|
950
|
+
</h4>
|
|
951
|
+
{allowBulkOperations && files.length > 1 && (
|
|
952
|
+
<Button
|
|
953
|
+
variant="ghost"
|
|
339
954
|
size="sm"
|
|
340
|
-
onClick={
|
|
341
|
-
disabled={uploadedFile.status === 'uploading'}
|
|
955
|
+
onClick={selectedIds.length === files.length ? handleClearSelection : handleSelectAll}
|
|
342
956
|
>
|
|
343
|
-
|
|
957
|
+
{selectedIds.length === files.length ? 'Seçimi Temizle' : 'Tümünü Seç'}
|
|
344
958
|
</Button>
|
|
345
|
-
|
|
959
|
+
)}
|
|
346
960
|
</div>
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
961
|
+
|
|
962
|
+
{/* File Items */}
|
|
963
|
+
<div className={cn(
|
|
964
|
+
"space-y-2",
|
|
965
|
+
variant === 'grid' && "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 space-y-0"
|
|
966
|
+
)}>
|
|
967
|
+
<AnimatePresence>
|
|
968
|
+
{files.map(file => (
|
|
969
|
+
<FileUploadItem
|
|
970
|
+
key={file.id}
|
|
971
|
+
file={file}
|
|
972
|
+
variant={variant}
|
|
973
|
+
showPreview={showPreview}
|
|
974
|
+
showProgress={showProgress}
|
|
975
|
+
showMetadata={showMetadata}
|
|
976
|
+
allowBulkOperations={allowBulkOperations}
|
|
977
|
+
resumable={resumable}
|
|
978
|
+
selected={selectedIds.includes(file.id)}
|
|
979
|
+
onSelect={(selected) => handleBulkSelect(file.id, selected)}
|
|
980
|
+
onRemove={() => removeFile(file.id)}
|
|
981
|
+
onPauseResume={() => pauseResumeUpload(file.id)}
|
|
982
|
+
onPreview={() => handlePreview(file)}
|
|
983
|
+
/>
|
|
984
|
+
))}
|
|
985
|
+
</AnimatePresence>
|
|
986
|
+
</div>
|
|
987
|
+
</motion.div>
|
|
988
|
+
)}
|
|
989
|
+
</AnimatePresence>
|
|
990
|
+
|
|
991
|
+
{/* Preview Modal */}
|
|
992
|
+
<FilePreviewModal
|
|
993
|
+
file={previewFile}
|
|
994
|
+
isOpen={isPreviewOpen}
|
|
995
|
+
onClose={() => setIsPreviewOpen(false)}
|
|
996
|
+
/>
|
|
997
|
+
</div>
|
|
998
|
+
)
|
|
999
|
+
}
|
|
1000
|
+
)
|
|
350
1001
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
1002
|
+
// Tekil dosya bileşeni
|
|
1003
|
+
const FileUploadItem = ({
|
|
1004
|
+
file,
|
|
1005
|
+
variant = 'default',
|
|
1006
|
+
showPreview = true,
|
|
1007
|
+
showProgress = true,
|
|
1008
|
+
showMetadata = true,
|
|
1009
|
+
allowBulkOperations = true,
|
|
1010
|
+
resumable = true,
|
|
1011
|
+
selected = false,
|
|
1012
|
+
onSelect,
|
|
1013
|
+
onRemove,
|
|
1014
|
+
onPauseResume,
|
|
1015
|
+
onPreview
|
|
1016
|
+
}: {
|
|
1017
|
+
file: FileUploadItem
|
|
1018
|
+
variant?: 'default' | 'compact' | 'grid'
|
|
1019
|
+
showPreview?: boolean
|
|
1020
|
+
showProgress?: boolean
|
|
1021
|
+
showMetadata?: boolean
|
|
1022
|
+
allowBulkOperations?: boolean
|
|
1023
|
+
resumable?: boolean
|
|
1024
|
+
selected?: boolean
|
|
1025
|
+
onSelect?: (selected: boolean) => void
|
|
1026
|
+
onRemove?: () => void
|
|
1027
|
+
onPauseResume?: () => void
|
|
1028
|
+
onPreview?: () => void
|
|
1029
|
+
}) => {
|
|
1030
|
+
const canPauseResume = resumable && ['uploading', 'paused'].includes(file.status)
|
|
1031
|
+
const canPreview = showPreview && file.preview && ['image', 'video', 'audio'].includes(file.preview.type)
|
|
1032
|
+
|
|
1033
|
+
return (
|
|
1034
|
+
<motion.div
|
|
1035
|
+
layout
|
|
1036
|
+
initial={{ opacity: 0, y: 10 }}
|
|
1037
|
+
animate={{ opacity: 1, y: 0 }}
|
|
1038
|
+
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
|
1039
|
+
className={cn(
|
|
1040
|
+
"group relative overflow-hidden rounded-lg border bg-card transition-all",
|
|
1041
|
+
selected && "ring-2 ring-primary bg-primary/5",
|
|
1042
|
+
variant === 'compact' && "p-3",
|
|
1043
|
+
variant === 'default' && "p-4",
|
|
1044
|
+
variant === 'grid' && "p-4"
|
|
1045
|
+
)}
|
|
1046
|
+
>
|
|
1047
|
+
{/* Selection Checkbox */}
|
|
1048
|
+
{allowBulkOperations && (
|
|
1049
|
+
<div className="absolute top-3 left-3 z-10">
|
|
1050
|
+
<input
|
|
1051
|
+
type="checkbox"
|
|
1052
|
+
checked={selected}
|
|
1053
|
+
onChange={(e) => onSelect?.(e.target.checked)}
|
|
1054
|
+
className="rounded border-muted-foreground/25"
|
|
1055
|
+
/>
|
|
1056
|
+
</div>
|
|
1057
|
+
)}
|
|
1058
|
+
|
|
1059
|
+
{/* Preview/Thumbnail */}
|
|
1060
|
+
{showPreview && file.preview && (
|
|
1061
|
+
<div className={cn(
|
|
1062
|
+
"relative overflow-hidden rounded bg-muted/20",
|
|
1063
|
+
variant === 'grid' ? "aspect-video mb-3" : "w-12 h-12 float-left mr-3"
|
|
1064
|
+
)}>
|
|
1065
|
+
{file.preview.type === 'image' && file.preview.thumbnail && (
|
|
1066
|
+
<img
|
|
1067
|
+
src={file.preview.thumbnail}
|
|
1068
|
+
alt={file.file.name}
|
|
1069
|
+
className="w-full h-full object-cover"
|
|
1070
|
+
/>
|
|
1071
|
+
)}
|
|
1072
|
+
|
|
1073
|
+
{file.preview.type === 'video' && file.preview.thumbnail && (
|
|
1074
|
+
<div className="relative w-full h-full">
|
|
1075
|
+
<img
|
|
1076
|
+
src={file.preview.thumbnail}
|
|
1077
|
+
alt={file.file.name}
|
|
1078
|
+
className="w-full h-full object-cover"
|
|
1079
|
+
/>
|
|
1080
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
|
1081
|
+
<Play className="h-6 w-6 text-white" />
|
|
1082
|
+
</div>
|
|
1083
|
+
</div>
|
|
1084
|
+
)}
|
|
1085
|
+
|
|
1086
|
+
{!file.preview.thumbnail && (
|
|
1087
|
+
<div className="w-full h-full flex items-center justify-center">
|
|
1088
|
+
{getFileIcon(file.file.type, variant === 'grid' ? 'lg' : 'md')}
|
|
1089
|
+
</div>
|
|
1090
|
+
)}
|
|
1091
|
+
|
|
1092
|
+
{canPreview && (
|
|
1093
|
+
<Button
|
|
1094
|
+
variant="secondary"
|
|
1095
|
+
size="sm"
|
|
1096
|
+
className="absolute top-1 right-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
1097
|
+
onClick={onPreview}
|
|
1098
|
+
>
|
|
1099
|
+
<Eye className="h-3 w-3" />
|
|
1100
|
+
</Button>
|
|
1101
|
+
)}
|
|
1102
|
+
</div>
|
|
1103
|
+
)}
|
|
1104
|
+
|
|
1105
|
+
{/* File Info */}
|
|
1106
|
+
<div className={cn(
|
|
1107
|
+
"flex-1 min-w-0",
|
|
1108
|
+
variant === 'grid' && "text-center"
|
|
1109
|
+
)}>
|
|
1110
|
+
<div className="flex items-start justify-between gap-2">
|
|
1111
|
+
<div className="flex-1 min-w-0">
|
|
1112
|
+
<h5 className={cn(
|
|
1113
|
+
"font-medium truncate",
|
|
1114
|
+
variant === 'compact' ? "text-sm" : "text-base"
|
|
1115
|
+
)}>
|
|
1116
|
+
{file.file.name}
|
|
1117
|
+
</h5>
|
|
1118
|
+
|
|
1119
|
+
{showMetadata && (
|
|
1120
|
+
<div className={cn(
|
|
1121
|
+
"flex items-center gap-2 mt-1 text-muted-foreground",
|
|
1122
|
+
variant === 'compact' ? "text-xs" : "text-sm",
|
|
1123
|
+
variant === 'grid' && "justify-center"
|
|
1124
|
+
)}>
|
|
1125
|
+
<span>{formatFileSize(file.file.size)}</span>
|
|
1126
|
+
{file.preview?.dimensions && (
|
|
1127
|
+
<>
|
|
1128
|
+
<span>•</span>
|
|
1129
|
+
<span>{file.preview.dimensions.width} × {file.preview.dimensions.height}</span>
|
|
1130
|
+
</>
|
|
1131
|
+
)}
|
|
1132
|
+
{file.preview?.duration && (
|
|
1133
|
+
<>
|
|
1134
|
+
<span>•</span>
|
|
1135
|
+
<span>{formatTime(file.preview.duration)}</span>
|
|
1136
|
+
</>
|
|
1137
|
+
)}
|
|
1138
|
+
</div>
|
|
1139
|
+
)}
|
|
356
1140
|
</div>
|
|
1141
|
+
|
|
1142
|
+
{/* Status Badge */}
|
|
1143
|
+
<Badge
|
|
1144
|
+
variant={
|
|
1145
|
+
file.status === 'success' ? 'success' :
|
|
1146
|
+
file.status === 'error' ? 'destructive' :
|
|
1147
|
+
file.status === 'paused' ? 'secondary' : 'secondary'
|
|
1148
|
+
}
|
|
1149
|
+
className="flex-shrink-0"
|
|
1150
|
+
>
|
|
1151
|
+
{file.status === 'uploading' && <Loader2 className="h-3 w-3 mr-1 animate-spin" />}
|
|
1152
|
+
{file.status === 'success' && <CheckCircle2 className="h-3 w-3 mr-1" />}
|
|
1153
|
+
{file.status === 'error' && <AlertCircle className="h-3 w-3 mr-1" />}
|
|
1154
|
+
{file.status === 'paused' && <Pause className="h-3 w-3 mr-1" />}
|
|
1155
|
+
{file.status === 'pending' ? 'Bekliyor' :
|
|
1156
|
+
file.status === 'uploading' ? 'Yükleniyor' :
|
|
1157
|
+
file.status === 'paused' ? 'Duraklatıldı' :
|
|
1158
|
+
file.status === 'success' ? 'Tamamlandı' :
|
|
1159
|
+
file.status === 'error' ? 'Hata' : 'İptal'}
|
|
1160
|
+
</Badge>
|
|
1161
|
+
</div>
|
|
1162
|
+
|
|
1163
|
+
{/* Progress */}
|
|
1164
|
+
{showProgress && file.status === 'uploading' && (
|
|
1165
|
+
<div className="mt-3 space-y-1">
|
|
1166
|
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
1167
|
+
<span>{file.progress}%</span>
|
|
1168
|
+
{file.speed && (
|
|
1169
|
+
<span>{formatFileSize(file.speed)}/s</span>
|
|
1170
|
+
)}
|
|
1171
|
+
{file.estimatedTime && file.estimatedTime > 0 && (
|
|
1172
|
+
<span>{formatTime(file.estimatedTime)} kaldı</span>
|
|
1173
|
+
)}
|
|
1174
|
+
</div>
|
|
1175
|
+
<Progress value={file.progress} className="h-1" />
|
|
1176
|
+
</div>
|
|
1177
|
+
)}
|
|
1178
|
+
|
|
1179
|
+
{/* Error Message */}
|
|
1180
|
+
{file.status === 'error' && file.error && (
|
|
1181
|
+
<p className="mt-2 text-xs text-destructive">{file.error}</p>
|
|
357
1182
|
)}
|
|
358
|
-
|
|
359
|
-
|
|
1183
|
+
|
|
1184
|
+
{/* Actions */}
|
|
1185
|
+
<div className={cn(
|
|
1186
|
+
"flex items-center gap-1 mt-3",
|
|
1187
|
+
variant === 'grid' && "justify-center"
|
|
1188
|
+
)}>
|
|
1189
|
+
{canPauseResume && (
|
|
1190
|
+
<Button
|
|
1191
|
+
variant="ghost"
|
|
1192
|
+
size="sm"
|
|
1193
|
+
onClick={onPauseResume}
|
|
1194
|
+
className="h-7 px-2"
|
|
1195
|
+
>
|
|
1196
|
+
{file.status === 'uploading' ? (
|
|
1197
|
+
<Pause className="h-3 w-3" />
|
|
1198
|
+
) : (
|
|
1199
|
+
<Play className="h-3 w-3" />
|
|
1200
|
+
)}
|
|
1201
|
+
</Button>
|
|
1202
|
+
)}
|
|
1203
|
+
|
|
1204
|
+
{file.status === 'error' && resumable && (
|
|
1205
|
+
<Button
|
|
1206
|
+
variant="ghost"
|
|
1207
|
+
size="sm"
|
|
1208
|
+
onClick={onPauseResume}
|
|
1209
|
+
className="h-7 px-2"
|
|
1210
|
+
>
|
|
1211
|
+
<RotateCcw className="h-3 w-3" />
|
|
1212
|
+
</Button>
|
|
1213
|
+
)}
|
|
1214
|
+
|
|
1215
|
+
{file.status === 'success' && (
|
|
1216
|
+
<Button
|
|
1217
|
+
variant="ghost"
|
|
1218
|
+
size="sm"
|
|
1219
|
+
className="h-7 px-2"
|
|
1220
|
+
>
|
|
1221
|
+
<Download className="h-3 w-3" />
|
|
1222
|
+
</Button>
|
|
1223
|
+
)}
|
|
1224
|
+
|
|
1225
|
+
<DropdownMenu>
|
|
1226
|
+
<DropdownMenuTrigger asChild>
|
|
1227
|
+
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
|
|
1228
|
+
<MoreHorizontal className="h-3 w-3" />
|
|
1229
|
+
</Button>
|
|
1230
|
+
</DropdownMenuTrigger>
|
|
1231
|
+
<DropdownMenuContent align="end">
|
|
1232
|
+
{canPreview && (
|
|
1233
|
+
<DropdownMenuItem onClick={onPreview}>
|
|
1234
|
+
<Eye className="mr-2 h-4 w-4" />
|
|
1235
|
+
Önizle
|
|
1236
|
+
</DropdownMenuItem>
|
|
1237
|
+
)}
|
|
1238
|
+
<DropdownMenuItem>
|
|
1239
|
+
<Copy className="mr-2 h-4 w-4" />
|
|
1240
|
+
Linki Kopyala
|
|
1241
|
+
</DropdownMenuItem>
|
|
1242
|
+
<DropdownMenuItem>
|
|
1243
|
+
<Share className="mr-2 h-4 w-4" />
|
|
1244
|
+
Paylaş
|
|
1245
|
+
</DropdownMenuItem>
|
|
1246
|
+
<DropdownMenuItem onClick={onRemove} className="text-destructive">
|
|
1247
|
+
<Trash2 className="mr-2 h-4 w-4" />
|
|
1248
|
+
Sil
|
|
1249
|
+
</DropdownMenuItem>
|
|
1250
|
+
</DropdownMenuContent>
|
|
1251
|
+
</DropdownMenu>
|
|
1252
|
+
</div>
|
|
1253
|
+
</div>
|
|
1254
|
+
</motion.div>
|
|
360
1255
|
)
|
|
361
1256
|
}
|
|
362
1257
|
|
|
363
|
-
|
|
1258
|
+
MoonUIFileUploadPro.displayName = "MoonUIFileUploadPro"
|
|
1259
|
+
|
|
1260
|
+
export default MoonUIFileUploadPro
|