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>
349 lines
12 KiB
TypeScript
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;
|