163 lines
5.6 KiB
TypeScript
163 lines
5.6 KiB
TypeScript
import { useCallback, useState } from 'react';
|
|
import { useDropzone } from 'react-dropzone';
|
|
import { Upload, X, Image, File } from 'lucide-react';
|
|
|
|
interface EnhancedFileUploadProps {
|
|
onFilesChange: (files: File[]) => void;
|
|
maxFiles?: number;
|
|
maxSize?: number; // in bytes
|
|
accept?: { [key: string]: string[] };
|
|
className?: string;
|
|
}
|
|
|
|
const EnhancedFileUpload: React.FC<EnhancedFileUploadProps> = ({
|
|
onFilesChange,
|
|
maxFiles = 5,
|
|
maxSize = 10 * 1024 * 1024, // 10MB
|
|
accept = {
|
|
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'],
|
|
'application/pdf': ['.pdf'],
|
|
'text/*': ['.txt', '.doc', '.docx']
|
|
},
|
|
className = ''
|
|
}) => {
|
|
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
|
|
|
|
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
|
setUploading(true);
|
|
|
|
const newFiles = [...uploadedFiles, ...acceptedFiles].slice(0, maxFiles);
|
|
setUploadedFiles(newFiles);
|
|
onFilesChange(newFiles);
|
|
|
|
// Simulate upload progress
|
|
for (const file of acceptedFiles) {
|
|
for (let progress = 0; progress <= 100; progress += 10) {
|
|
setUploadProgress(prev => ({ ...prev, [file.name]: progress }));
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
}
|
|
}
|
|
|
|
setUploading(false);
|
|
setUploadProgress({});
|
|
}, [uploadedFiles, maxFiles, onFilesChange]);
|
|
|
|
const removeFile = (fileToRemove: File) => {
|
|
const newFiles = uploadedFiles.filter(file => file !== fileToRemove);
|
|
setUploadedFiles(newFiles);
|
|
onFilesChange(newFiles);
|
|
};
|
|
|
|
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
|
|
onDrop,
|
|
accept,
|
|
maxSize,
|
|
maxFiles: maxFiles - uploadedFiles.length,
|
|
disabled: uploading
|
|
});
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
};
|
|
|
|
const getFileIcon = (file: File) => {
|
|
if (file.type.startsWith('image/')) {
|
|
return <Image className="w-5 h-5" />;
|
|
}
|
|
return <File className="w-5 h-5" />;
|
|
};
|
|
|
|
return (
|
|
<div className={`file-upload-enhanced ${className}`}>
|
|
{/* Dropzone */}
|
|
<div
|
|
{...getRootProps()}
|
|
className={`border-2 border-dashed rounded-xl p-6 text-center transition-all cursor-pointer ${
|
|
isDragActive
|
|
? 'border-primary bg-primary/5'
|
|
: 'border-gray-300 hover:border-primary hover:bg-primary/5'
|
|
} ${uploading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
>
|
|
<input {...getInputProps()} />
|
|
|
|
<div className="space-y-4">
|
|
<div className="mx-auto w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
|
|
<Upload className="w-8 h-8 text-primary" />
|
|
</div>
|
|
|
|
<div>
|
|
<h6 className="font-semibold text-gray-800 mb-2">
|
|
{isDragActive ? 'Drop files here' : 'Drag & drop files here'}
|
|
</h6>
|
|
<p className="text-gray-600 text-sm mb-3">
|
|
or <span className="text-primary font-medium">browse files</span>
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
Max {maxFiles} files, up to {formatFileSize(maxSize)} each
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* File Rejections */}
|
|
{fileRejections.length > 0 && (
|
|
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
<h6 className="text-red-800 font-medium mb-1">Upload Errors:</h6>
|
|
{fileRejections.map(({ file, errors }) => (
|
|
<div key={file.name} className="text-sm text-red-600">
|
|
{file.name}: {errors.map(e => e.message).join(', ')}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Uploaded Files List */}
|
|
{uploadedFiles.length > 0 && (
|
|
<div className="mt-4 space-y-2">
|
|
<h6 className="font-medium text-gray-800">Uploaded Files ({uploadedFiles.length})</h6>
|
|
|
|
{uploadedFiles.map((file, index) => (
|
|
<div key={`${file.name}-${index}`} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="text-primary">
|
|
{getFileIcon(file)}
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-gray-800 text-sm">{file.name}</p>
|
|
<p className="text-xs text-gray-500">{formatFileSize(file.size)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
{uploadProgress[file.name] !== undefined && (
|
|
<div className="w-20 bg-gray-200 rounded-full h-2">
|
|
<div
|
|
className="bg-primary h-2 rounded-full transition-all"
|
|
style={{ width: `${uploadProgress[file.name]}%` }}
|
|
></div>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => removeFile(file)}
|
|
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded"
|
|
disabled={uploading}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default EnhancedFileUpload; |