Files
Atomizer/atomizer-dashboard/frontend/src/components/intake/FileDropzone.tsx
Anto01 a26914bbe8 feat: Add Studio UI, intake system, and extractor improvements
Dashboard:
- Add Studio page with drag-drop model upload and Claude chat
- Add intake system for study creation workflow
- Improve session manager and context builder
- Add intake API routes and frontend components

Optimization Engine:
- Add CLI module for command-line operations
- Add intake module for study preprocessing
- Add validation module with gate checks
- Improve Zernike extractor documentation
- Update spec models with better validation
- Enhance solve_simulation robustness

Documentation:
- Add ATOMIZER_STUDIO.md planning doc
- Add ATOMIZER_UX_SYSTEM.md for UX patterns
- Update extractor library docs
- Add study-readme-generator skill

Tools:
- Add test scripts for extraction validation
- Add Zernike recentering test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:02:30 -05:00

349 lines
12 KiB
TypeScript

/**
* 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<FileDropzoneProps> = ({
studyName,
onUploadComplete,
compact = false,
}) => {
const [isDragging, setIsDragging] = useState(false);
const [files, setFiles] = useState<FileStatus[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white
transition-colors"
>
<Upload className="w-4 h-4" />
Add Files
</button>
{pendingCount > 0 && (
<button
onClick={handleUpload}
disabled={isUploading}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-primary-500/10 text-primary-400 hover:bg-primary-500/20
disabled:opacity-50 transition-colors"
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
Upload {pendingCount} {pendingCount === 1 ? 'File' : 'Files'}
</button>
)}
</div>
{/* File list */}
{files.length > 0 && (
<div className="flex flex-wrap gap-2">
{files.map((f, i) => (
<span
key={i}
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs
${f.status === 'error' ? 'bg-red-500/10 text-red-400' :
f.status === 'success' ? 'bg-green-500/10 text-green-400' :
'bg-dark-700 text-dark-300'}`}
>
{f.status === 'uploading' && <Loader2 className="w-3 h-3 animate-spin" />}
{f.status === 'success' && <CheckCircle className="w-3 h-3" />}
{f.status === 'error' && <AlertCircle className="w-3 h-3" />}
{f.file.name}
{f.status === 'pending' && (
<button onClick={() => removeFile(i)} className="hover:text-white">
<X className="w-3 h-3" />
</button>
)}
</span>
))}
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept={VALID_EXTENSIONS.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
}
// Full dropzone version
return (
<div className="space-y-4">
{/* Dropzone */}
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => 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'
}
`}
>
<div className="flex flex-col items-center text-center">
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-3
${isDragging ? 'bg-primary-400/20 text-primary-400' : 'bg-dark-700 text-dark-400'}`}>
<Upload className="w-6 h-6" />
</div>
<p className="text-white font-medium mb-1">
{isDragging ? 'Drop files here' : 'Drop model files here'}
</p>
<p className="text-sm text-dark-400">
or <span className="text-primary-400">click to browse</span>
</p>
<p className="text-xs text-dark-500 mt-2">
Accepts: {VALID_EXTENSIONS.join(', ')}
</p>
</div>
</div>
{/* Error */}
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* File List */}
{files.length > 0 && (
<div className="space-y-2">
<h5 className="text-sm font-medium text-dark-300">Files to Upload</h5>
<div className="space-y-1">
{files.map((f, i) => (
<div
key={i}
className={`flex items-center justify-between p-2 rounded-lg
${f.status === 'error' ? 'bg-red-500/10' :
f.status === 'success' ? 'bg-green-500/10' :
'bg-dark-700'}`}
>
<div className="flex items-center gap-2">
{f.status === 'pending' && <FileText className="w-4 h-4 text-dark-400" />}
{f.status === 'uploading' && <Loader2 className="w-4 h-4 text-primary-400 animate-spin" />}
{f.status === 'success' && <CheckCircle className="w-4 h-4 text-green-400" />}
{f.status === 'error' && <AlertCircle className="w-4 h-4 text-red-400" />}
<span className={`text-sm ${f.status === 'error' ? 'text-red-400' :
f.status === 'success' ? 'text-green-400' :
'text-white'}`}>
{f.file.name}
</span>
{f.message && (
<span className="text-xs text-red-400">({f.message})</span>
)}
</div>
{f.status === 'pending' && (
<button
onClick={(e) => {
e.stopPropagation();
removeFile(i);
}}
className="p-1 hover:bg-white/10 rounded text-dark-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
{/* Upload Button */}
{pendingCount > 0 && (
<button
onClick={handleUpload}
disabled={isUploading}
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg
bg-primary-500 text-white font-medium
hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {pendingCount} {pendingCount === 1 ? 'File' : 'Files'}
</>
)}
</button>
)}
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept={VALID_EXTENSIONS.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
};
export default FileDropzone;