/** * FileDropzone - Drag and drop file upload component * * Supports drag-and-drop or click-to-browse for model files. * Accepts .prt, .sim, .fem, .afem files. */ import React, { useState, useCallback, useRef } from 'react'; import { Upload, FileText, X, Loader2, AlertCircle, CheckCircle } from 'lucide-react'; import { intakeApi } from '../../api/intake'; interface FileDropzoneProps { studyName: string; onUploadComplete: () => void; compact?: boolean; } interface FileStatus { file: File; status: 'pending' | 'uploading' | 'success' | 'error'; message?: string; } const VALID_EXTENSIONS = ['.prt', '.sim', '.fem', '.afem']; export const FileDropzone: React.FC = ({ studyName, onUploadComplete, compact = false, }) => { const [isDragging, setIsDragging] = useState(false); const [files, setFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); const [error, setError] = useState(null); const fileInputRef = useRef(null); const validateFile = (file: File): { valid: boolean; reason?: string } => { const ext = '.' + file.name.split('.').pop()?.toLowerCase(); if (!VALID_EXTENSIONS.includes(ext)) { return { valid: false, reason: `Invalid type: ${ext}` }; } // Max 500MB per file if (file.size > 500 * 1024 * 1024) { return { valid: false, reason: 'File too large (max 500MB)' }; } return { valid: true }; }; const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }, []); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); const addFiles = useCallback((newFiles: File[]) => { const validFiles: FileStatus[] = []; for (const file of newFiles) { // Skip duplicates if (files.some(f => f.file.name === file.name)) { continue; } const validation = validateFile(file); if (validation.valid) { validFiles.push({ file, status: 'pending' }); } else { validFiles.push({ file, status: 'error', message: validation.reason }); } } setFiles(prev => [...prev, ...validFiles]); }, [files]); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const droppedFiles = Array.from(e.dataTransfer.files); addFiles(droppedFiles); }, [addFiles]); const handleFileSelect = useCallback((e: React.ChangeEvent) => { const selectedFiles = Array.from(e.target.files || []); addFiles(selectedFiles); // Reset input so the same file can be selected again e.target.value = ''; }, [addFiles]); const removeFile = (index: number) => { setFiles(prev => prev.filter((_, i) => i !== index)); }; const handleUpload = async () => { const pendingFiles = files.filter(f => f.status === 'pending'); if (pendingFiles.length === 0) return; setIsUploading(true); setError(null); try { // Upload files const response = await intakeApi.uploadFiles( studyName, pendingFiles.map(f => f.file) ); // Update file statuses based on response const uploadResults = new Map( response.uploaded_files.map(f => [f.name, f.status === 'uploaded']) ); setFiles(prev => prev.map(f => { if (f.status !== 'pending') return f; const success = uploadResults.get(f.file.name); return { ...f, status: success ? 'success' : 'error', message: success ? undefined : 'Upload failed', }; })); // Clear successful uploads after a moment and refresh setTimeout(() => { setFiles(prev => prev.filter(f => f.status !== 'success')); onUploadComplete(); }, 1500); } catch (err) { setError(err instanceof Error ? err.message : 'Upload failed'); setFiles(prev => prev.map(f => f.status === 'pending' ? { ...f, status: 'error', message: 'Upload failed' } : f )); } finally { setIsUploading(false); } }; const pendingCount = files.filter(f => f.status === 'pending').length; if (compact) { // Compact inline version return (
{pendingCount > 0 && ( )}
{/* File list */} {files.length > 0 && (
{files.map((f, i) => ( {f.status === 'uploading' && } {f.status === 'success' && } {f.status === 'error' && } {f.file.name} {f.status === 'pending' && ( )} ))}
)}
); } // Full dropzone version return (
{/* Dropzone */}
fileInputRef.current?.click()} className={` relative border-2 border-dashed rounded-xl p-6 cursor-pointer transition-all duration-200 ${isDragging ? 'border-primary-400 bg-primary-400/5' : 'border-dark-600 hover:border-primary-400/50 hover:bg-white/5' } `} >

{isDragging ? 'Drop files here' : 'Drop model files here'}

or click to browse

Accepts: {VALID_EXTENSIONS.join(', ')}

{/* Error */} {error && (
{error}
)} {/* File List */} {files.length > 0 && (
Files to Upload
{files.map((f, i) => (
{f.status === 'pending' && } {f.status === 'uploading' && } {f.status === 'success' && } {f.status === 'error' && } {f.file.name} {f.message && ( ({f.message}) )}
{f.status === 'pending' && ( )}
))}
{/* Upload Button */} {pendingCount > 0 && ( )}
)}
); }; export default FileDropzone;