2025-10-26 00:19:18 +00:00
|
|
|
|
import { forwardRef, useState, useCallback, useRef } from 'react'
|
|
|
|
|
|
import { Dialog, Button, Progress } from '@/components/ui'
|
|
|
|
|
|
import { HiCloudArrowUp, HiXMark } from 'react-icons/hi2'
|
|
|
|
|
|
import classNames from 'classnames'
|
|
|
|
|
|
|
|
|
|
|
|
export interface FileUploadModalProps {
|
|
|
|
|
|
isOpen: boolean
|
|
|
|
|
|
onClose: () => void
|
|
|
|
|
|
onUpload: (files: File[]) => Promise<void>
|
|
|
|
|
|
currentFolderId?: string
|
|
|
|
|
|
loading?: boolean
|
|
|
|
|
|
className?: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-26 16:27:19 +00:00
|
|
|
|
interface UploadFileWithProgress {
|
2025-10-26 00:19:18 +00:00
|
|
|
|
id: string
|
2025-10-26 16:27:19 +00:00
|
|
|
|
file: File
|
2025-10-26 00:19:18 +00:00
|
|
|
|
progress: number
|
|
|
|
|
|
status: 'pending' | 'uploading' | 'completed' | 'error'
|
|
|
|
|
|
error?: string
|
2025-10-26 16:27:19 +00:00
|
|
|
|
errorDetail?: string
|
2025-10-26 00:19:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props, ref) => {
|
|
|
|
|
|
const { isOpen, onClose, onUpload, currentFolderId, loading = false, className } = props
|
|
|
|
|
|
|
|
|
|
|
|
const [uploadFiles, setUploadFiles] = useState<UploadFileWithProgress[]>([])
|
|
|
|
|
|
const [isDragOver, setIsDragOver] = useState(false)
|
|
|
|
|
|
const [uploading, setUploading] = useState(false)
|
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
|
|
|
|
|
|
|
|
const generateFileId = () => Math.random().toString(36).substr(2, 9)
|
|
|
|
|
|
|
|
|
|
|
|
const formatFileSize = (bytes: number): string => {
|
|
|
|
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
2025-10-26 16:27:19 +00:00
|
|
|
|
|
|
|
|
|
|
// Handle undefined, null, NaN values
|
|
|
|
|
|
if (!bytes || bytes === 0 || isNaN(bytes)) return '0 B'
|
2025-10-26 00:19:18 +00:00
|
|
|
|
|
|
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
|
|
|
|
|
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + ' ' + sizes[i]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleFilesSelect = useCallback((files: FileList | File[]) => {
|
|
|
|
|
|
const fileArray = Array.from(files)
|
|
|
|
|
|
const newFiles: UploadFileWithProgress[] = fileArray.map((file) => ({
|
|
|
|
|
|
id: generateFileId(),
|
2025-10-26 16:27:19 +00:00
|
|
|
|
file: file,
|
2025-10-26 00:19:18 +00:00
|
|
|
|
progress: 0,
|
|
|
|
|
|
status: 'pending' as const,
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
setUploadFiles((prev) => [...prev, ...newFiles])
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
|
if (e.target.files) {
|
|
|
|
|
|
handleFilesSelect(e.target.files)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
setIsDragOver(true)
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
setIsDragOver(false)
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
const handleDrop = useCallback(
|
|
|
|
|
|
(e: React.DragEvent) => {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
setIsDragOver(false)
|
|
|
|
|
|
|
|
|
|
|
|
if (e.dataTransfer.files) {
|
|
|
|
|
|
handleFilesSelect(e.dataTransfer.files)
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
[handleFilesSelect],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const removeFile = (fileId: string) => {
|
|
|
|
|
|
setUploadFiles((prev) => prev.filter((f) => f.id !== fileId))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleUpload = async () => {
|
|
|
|
|
|
if (uploadFiles.length === 0) return
|
|
|
|
|
|
|
|
|
|
|
|
setUploading(true)
|
|
|
|
|
|
const filesToUpload = uploadFiles.filter((f) => f.status === 'pending')
|
|
|
|
|
|
|
2025-10-26 16:27:19 +00:00
|
|
|
|
// Upload files one by one
|
|
|
|
|
|
for (const fileData of filesToUpload) {
|
|
|
|
|
|
let progressInterval: NodeJS.Timeout | null = null
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Set status to uploading
|
|
|
|
|
|
setUploadFiles((prev) =>
|
|
|
|
|
|
prev.map((f) => (f.id === fileData.id ? { ...f, status: 'uploading' as const } : f)),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Simulate progress for visual feedback
|
|
|
|
|
|
progressInterval = setInterval(() => {
|
|
|
|
|
|
setUploadFiles((prev) =>
|
|
|
|
|
|
prev.map((f) => {
|
|
|
|
|
|
if (f.id === fileData.id && f.progress < 90) {
|
|
|
|
|
|
return { ...f, progress: f.progress + 10 }
|
|
|
|
|
|
}
|
|
|
|
|
|
return f
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
}, 100)
|
|
|
|
|
|
|
|
|
|
|
|
// Call the actual upload function for single file
|
|
|
|
|
|
await onUpload([fileData.file])
|
|
|
|
|
|
|
|
|
|
|
|
// Clear progress interval
|
|
|
|
|
|
if (progressInterval) {
|
|
|
|
|
|
clearInterval(progressInterval)
|
|
|
|
|
|
progressInterval = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Mark as completed and remove from list after delay
|
2025-10-26 00:19:18 +00:00
|
|
|
|
setUploadFiles((prev) =>
|
2025-10-26 16:27:19 +00:00
|
|
|
|
prev.map((f) =>
|
|
|
|
|
|
f.id === fileData.id ? { ...f, status: 'completed' as const, progress: 100 } : f,
|
|
|
|
|
|
),
|
2025-10-26 00:19:18 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2025-10-26 16:27:19 +00:00
|
|
|
|
// Remove completed files from list after 2 seconds
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
setUploadFiles((prev) => prev.filter((f) => f.id !== fileData.id))
|
|
|
|
|
|
}, 2000)
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error('Upload failed for file:', fileData.file.name, error)
|
|
|
|
|
|
|
|
|
|
|
|
// Clear progress interval in case of error
|
|
|
|
|
|
if (progressInterval) {
|
|
|
|
|
|
clearInterval(progressInterval)
|
|
|
|
|
|
progressInterval = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Extract detailed error message from ABP response
|
|
|
|
|
|
let errorMessage = 'Upload failed'
|
|
|
|
|
|
let detailMessage = ''
|
|
|
|
|
|
|
|
|
|
|
|
if (error?.response?.data?.error) {
|
|
|
|
|
|
const errorData = error.response.data.error
|
|
|
|
|
|
|
|
|
|
|
|
// Ana hata mesajı
|
|
|
|
|
|
if (errorData.message) {
|
|
|
|
|
|
errorMessage = errorData.message
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Detay mesajı - validationErrors veya details'den
|
|
|
|
|
|
if (errorData.details) {
|
|
|
|
|
|
detailMessage = errorData.details
|
|
|
|
|
|
} else if (errorData.validationErrors && errorData.validationErrors.length > 0) {
|
|
|
|
|
|
detailMessage = errorData.validationErrors[0].message || errorData.validationErrors[0]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Dosya boyutu kontrolü için özel mesaj
|
|
|
|
|
|
if (detailMessage.includes('Request body too large') || detailMessage.includes('max request body size')) {
|
|
|
|
|
|
const maxSizeMB = 30 // 30MB limit
|
|
|
|
|
|
errorMessage = 'File too large'
|
|
|
|
|
|
detailMessage = `File size exceeds the maximum allowed size of ${maxSizeMB}MB. Your file is ${(fileData.file.size / (1024 * 1024)).toFixed(1)}MB.`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} else if (error?.message) {
|
|
|
|
|
|
errorMessage = error.message
|
|
|
|
|
|
} else if (typeof error === 'string') {
|
|
|
|
|
|
errorMessage = error
|
2025-10-26 00:19:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-26 16:27:19 +00:00
|
|
|
|
// Mark as error with detailed message
|
2025-10-26 00:19:18 +00:00
|
|
|
|
setUploadFiles((prev) =>
|
2025-10-26 16:27:19 +00:00
|
|
|
|
prev.map((f) =>
|
|
|
|
|
|
f.id === fileData.id
|
|
|
|
|
|
? {
|
|
|
|
|
|
...f,
|
|
|
|
|
|
status: 'error' as const,
|
|
|
|
|
|
error: errorMessage,
|
|
|
|
|
|
errorDetail: detailMessage,
|
|
|
|
|
|
progress: 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
: f,
|
|
|
|
|
|
),
|
2025-10-26 00:19:18 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
2025-10-26 16:27:19 +00:00
|
|
|
|
}
|
2025-10-26 00:19:18 +00:00
|
|
|
|
|
2025-10-26 16:27:19 +00:00
|
|
|
|
setUploading(false)
|
|
|
|
|
|
|
|
|
|
|
|
// Check if all files are processed (completed or error)
|
|
|
|
|
|
const remainingFiles = uploadFiles.filter(
|
|
|
|
|
|
(f) => f.status === 'pending' || f.status === 'uploading',
|
|
|
|
|
|
)
|
|
|
|
|
|
if (remainingFiles.length === 0) {
|
|
|
|
|
|
// If no pending files and no errors, close modal
|
|
|
|
|
|
const hasErrors = uploadFiles.some((f) => f.status === 'error')
|
|
|
|
|
|
if (!hasErrors) {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
onClose()
|
|
|
|
|
|
setUploadFiles([])
|
|
|
|
|
|
}, 2000)
|
|
|
|
|
|
}
|
2025-10-26 00:19:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleClose = () => {
|
|
|
|
|
|
if (!uploading) {
|
|
|
|
|
|
setUploadFiles([])
|
|
|
|
|
|
onClose()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-26 16:27:19 +00:00
|
|
|
|
const clearCompletedFiles = () => {
|
|
|
|
|
|
setUploadFiles((prev) => prev.filter((f) => f.status !== 'completed'))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const clearErrorFiles = () => {
|
|
|
|
|
|
setUploadFiles((prev) => prev.filter((f) => f.status !== 'error'))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-26 00:19:18 +00:00
|
|
|
|
const totalFiles = uploadFiles.length
|
|
|
|
|
|
const completedFiles = uploadFiles.filter((f) => f.status === 'completed').length
|
2025-10-26 16:27:19 +00:00
|
|
|
|
const errorFiles = uploadFiles.filter((f) => f.status === 'error').length
|
|
|
|
|
|
const pendingFiles = uploadFiles.filter((f) => f.status === 'pending').length
|
|
|
|
|
|
const hasError = errorFiles > 0
|
2025-10-26 00:19:18 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Dialog isOpen={isOpen} onClose={handleClose} className={className}>
|
|
|
|
|
|
<div ref={ref}>
|
2025-10-26 16:27:19 +00:00
|
|
|
|
<div className="flex items-center justify-between pb-2 border-b">
|
2025-10-26 00:19:18 +00:00
|
|
|
|
<h3 className="text-lg font-semibold">Upload Files</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-26 16:27:19 +00:00
|
|
|
|
<div className="py-2">
|
2025-10-26 00:19:18 +00:00
|
|
|
|
<div
|
|
|
|
|
|
className={classNames(
|
2025-10-26 16:27:19 +00:00
|
|
|
|
'relative border-2 border-dashed rounded-lg p-4 text-center transition-colors',
|
2025-10-26 00:19:18 +00:00
|
|
|
|
isDragOver
|
|
|
|
|
|
? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
|
|
|
|
|
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400',
|
|
|
|
|
|
)}
|
|
|
|
|
|
onDragOver={handleDragOver}
|
|
|
|
|
|
onDragLeave={handleDragLeave}
|
|
|
|
|
|
onDrop={handleDrop}
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
multiple
|
|
|
|
|
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
|
|
|
|
onChange={handleFileInputChange}
|
|
|
|
|
|
disabled={uploading}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<HiCloudArrowUp className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
|
|
|
|
|
<p className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
|
|
|
|
|
Choose files or drag and drop here
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
|
|
|
|
|
Select one or more files to upload
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* File List */}
|
|
|
|
|
|
{uploadFiles.length > 0 && (
|
2025-10-26 16:27:19 +00:00
|
|
|
|
<div className="space-y-1 max-h-80 overflow-y-auto">
|
|
|
|
|
|
{uploadFiles.map((file) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={file.id}
|
|
|
|
|
|
className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded-lg"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
{/* File name and size in one line */}
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate flex-1 mr-2">
|
|
|
|
|
|
{file.file.name}
|
2025-10-26 00:19:18 +00:00
|
|
|
|
</p>
|
2025-10-26 16:27:19 +00:00
|
|
|
|
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
|
|
|
|
|
|
{formatFileSize(file.file.size)}
|
|
|
|
|
|
</span>
|
2025-10-26 00:19:18 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-26 16:27:19 +00:00
|
|
|
|
{/* Progress Bar */}
|
|
|
|
|
|
{file.status === 'uploading' && (
|
|
|
|
|
|
<div className="mt-1">
|
|
|
|
|
|
<Progress percent={file.progress} size="sm" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Status Messages */}
|
|
|
|
|
|
{file.status === 'completed' && (
|
|
|
|
|
|
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
|
|
|
|
|
✓ Upload completed
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{file.status === 'error' && (
|
|
|
|
|
|
<div className="mt-1">
|
|
|
|
|
|
<p className="text-xs text-red-600 dark:text-red-400 font-medium">
|
|
|
|
|
|
✗ {file.error || 'Upload failed'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{file.errorDetail && (
|
|
|
|
|
|
<p className="text-xs text-red-500 dark:text-red-400 mt-0.5 leading-relaxed">
|
|
|
|
|
|
{file.errorDetail}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-10-26 00:19:18 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-10-26 16:27:19 +00:00
|
|
|
|
|
|
|
|
|
|
{(file.status === 'pending' || file.status === 'error') && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
size="xs"
|
|
|
|
|
|
icon={<HiXMark />}
|
|
|
|
|
|
onClick={() => removeFile(file.id)}
|
|
|
|
|
|
disabled={uploading}
|
|
|
|
|
|
className="ml-2 flex-shrink-0"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2025-10-26 00:19:18 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Footer */}
|
|
|
|
|
|
<div className="flex justify-between items-center pt-4 border-t">
|
|
|
|
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
|
|
|
|
{uploading && (
|
|
|
|
|
|
<span>
|
2025-10-26 16:27:19 +00:00
|
|
|
|
Uploading files... {completedFiles > 0 && `(${completedFiles} completed)`}
|
2025-10-26 00:19:18 +00:00
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2025-10-26 16:27:19 +00:00
|
|
|
|
{!uploading && !hasError && pendingFiles > 0 && (
|
|
|
|
|
|
<span className="text-green-600">Ready to upload {pendingFiles} file(s)</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{!uploading && !hasError && pendingFiles === 0 && totalFiles === 0 && (
|
|
|
|
|
|
<span className="text-gray-500">No files selected</span>
|
2025-10-26 00:19:18 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex space-x-2">
|
2025-10-26 16:27:19 +00:00
|
|
|
|
{hasError && !uploading && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="plain"
|
|
|
|
|
|
onClick={clearErrorFiles}
|
|
|
|
|
|
className="text-red-600 hover:text-red-700"
|
|
|
|
|
|
>
|
|
|
|
|
|
Clear Errors
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2025-10-26 00:19:18 +00:00
|
|
|
|
<Button variant="default" onClick={handleClose} disabled={uploading}>
|
2025-10-26 16:27:19 +00:00
|
|
|
|
{uploading ? 'Uploading...' : 'Close'}
|
2025-10-26 00:19:18 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="solid"
|
|
|
|
|
|
onClick={handleUpload}
|
2025-10-26 16:27:19 +00:00
|
|
|
|
disabled={pendingFiles === 0 || uploading}
|
2025-10-26 00:19:18 +00:00
|
|
|
|
loading={uploading}
|
|
|
|
|
|
>
|
2025-10-26 16:27:19 +00:00
|
|
|
|
{uploading ? 'Uploading...' : `Upload ${pendingFiles} File(s)`}
|
2025-10-26 00:19:18 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
FileUploadModal.displayName = 'FileUploadModal'
|
|
|
|
|
|
|
|
|
|
|
|
export default FileUploadModal
|