378 lines
13 KiB
TypeScript
378 lines
13 KiB
TypeScript
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
|
||
}
|
||
|
||
interface UploadFileWithProgress {
|
||
id: string
|
||
file: File
|
||
progress: number
|
||
status: 'pending' | 'uploading' | 'completed' | 'error'
|
||
error?: string
|
||
errorDetail?: string
|
||
}
|
||
|
||
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']
|
||
|
||
// Handle undefined, null, NaN values
|
||
if (!bytes || bytes === 0 || isNaN(bytes)) return '0 B'
|
||
|
||
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(),
|
||
file: file,
|
||
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')
|
||
|
||
// 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
|
||
setUploadFiles((prev) =>
|
||
prev.map((f) =>
|
||
f.id === fileData.id ? { ...f, status: 'completed' as const, progress: 100 } : f,
|
||
),
|
||
)
|
||
|
||
// 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
|
||
}
|
||
|
||
// Mark as error with detailed message
|
||
setUploadFiles((prev) =>
|
||
prev.map((f) =>
|
||
f.id === fileData.id
|
||
? {
|
||
...f,
|
||
status: 'error' as const,
|
||
error: errorMessage,
|
||
errorDetail: detailMessage,
|
||
progress: 0,
|
||
}
|
||
: f,
|
||
),
|
||
)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleClose = () => {
|
||
if (!uploading) {
|
||
setUploadFiles([])
|
||
onClose()
|
||
}
|
||
}
|
||
|
||
const clearCompletedFiles = () => {
|
||
setUploadFiles((prev) => prev.filter((f) => f.status !== 'completed'))
|
||
}
|
||
|
||
const clearErrorFiles = () => {
|
||
setUploadFiles((prev) => prev.filter((f) => f.status !== 'error'))
|
||
}
|
||
|
||
const totalFiles = uploadFiles.length
|
||
const completedFiles = uploadFiles.filter((f) => f.status === 'completed').length
|
||
const errorFiles = uploadFiles.filter((f) => f.status === 'error').length
|
||
const pendingFiles = uploadFiles.filter((f) => f.status === 'pending').length
|
||
const hasError = errorFiles > 0
|
||
|
||
return (
|
||
<Dialog isOpen={isOpen} onClose={handleClose} className={className}>
|
||
<div ref={ref}>
|
||
<div className="flex items-center justify-between pb-2 border-b">
|
||
<h3 className="text-lg font-semibold">Upload Files</h3>
|
||
</div>
|
||
|
||
<div className="py-2">
|
||
<div
|
||
className={classNames(
|
||
'relative border-2 border-dashed rounded-lg p-4 text-center transition-colors',
|
||
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 && (
|
||
<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}
|
||
</p>
|
||
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||
{formatFileSize(file.file.size)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 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>
|
||
)}
|
||
</div>
|
||
|
||
{(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>
|
||
))}
|
||
</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>
|
||
Uploading files... {completedFiles > 0 && `(${completedFiles} completed)`}
|
||
</span>
|
||
)}
|
||
{!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>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex space-x-2">
|
||
{hasError && !uploading && (
|
||
<Button
|
||
variant="plain"
|
||
onClick={clearErrorFiles}
|
||
className="text-red-600 hover:text-red-700"
|
||
>
|
||
Clear Errors
|
||
</Button>
|
||
)}
|
||
<Button variant="default" onClick={handleClose} disabled={uploading}>
|
||
{uploading ? 'Uploading...' : 'Close'}
|
||
</Button>
|
||
<Button
|
||
variant="solid"
|
||
onClick={handleUpload}
|
||
disabled={pendingFiles === 0 || uploading}
|
||
loading={uploading}
|
||
>
|
||
{uploading ? 'Uploading...' : `Upload ${pendingFiles} File(s)`}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Dialog>
|
||
)
|
||
})
|
||
|
||
FileUploadModal.displayName = 'FileUploadModal'
|
||
|
||
export default FileUploadModal
|