243 lines
7.7 KiB
TypeScript
243 lines
7.7 KiB
TypeScript
|
|
/**
|
||
|
|
* StudioDropZone - Smart file drop zone for Studio
|
||
|
|
*
|
||
|
|
* Handles both model files (.sim, .prt, .fem) and context files (.pdf, .md, .txt)
|
||
|
|
*/
|
||
|
|
|
||
|
|
import React, { useState, useCallback, useRef } from 'react';
|
||
|
|
import { Upload, X, Loader2, AlertCircle, CheckCircle, File } from 'lucide-react';
|
||
|
|
import { intakeApi } from '../../api/intake';
|
||
|
|
|
||
|
|
interface StudioDropZoneProps {
|
||
|
|
draftId: string;
|
||
|
|
type: 'model' | 'context';
|
||
|
|
files: string[];
|
||
|
|
onUploadComplete: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface FileStatus {
|
||
|
|
file: File;
|
||
|
|
status: 'pending' | 'uploading' | 'success' | 'error';
|
||
|
|
message?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
const MODEL_EXTENSIONS = ['.prt', '.sim', '.fem', '.afem'];
|
||
|
|
const CONTEXT_EXTENSIONS = ['.md', '.txt', '.pdf', '.json', '.csv', '.docx'];
|
||
|
|
|
||
|
|
export const StudioDropZone: React.FC<StudioDropZoneProps> = ({
|
||
|
|
draftId,
|
||
|
|
type,
|
||
|
|
files,
|
||
|
|
onUploadComplete,
|
||
|
|
}) => {
|
||
|
|
const [isDragging, setIsDragging] = useState(false);
|
||
|
|
const [pendingFiles, setPendingFiles] = useState<FileStatus[]>([]);
|
||
|
|
const [isUploading, setIsUploading] = useState(false);
|
||
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
|
|
||
|
|
const validExtensions = type === 'model' ? MODEL_EXTENSIONS : CONTEXT_EXTENSIONS;
|
||
|
|
|
||
|
|
const validateFile = (file: File): { valid: boolean; reason?: string } => {
|
||
|
|
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||
|
|
if (!validExtensions.includes(ext)) {
|
||
|
|
return { valid: false, reason: `Invalid type: ${ext}` };
|
||
|
|
}
|
||
|
|
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) {
|
||
|
|
if (pendingFiles.some(f => f.file.name === file.name)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const validation = validateFile(file);
|
||
|
|
validFiles.push({
|
||
|
|
file,
|
||
|
|
status: validation.valid ? 'pending' : 'error',
|
||
|
|
message: validation.reason,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
setPendingFiles(prev => [...prev, ...validFiles]);
|
||
|
|
}, [pendingFiles, validExtensions]);
|
||
|
|
|
||
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
e.stopPropagation();
|
||
|
|
setIsDragging(false);
|
||
|
|
addFiles(Array.from(e.dataTransfer.files));
|
||
|
|
}, [addFiles]);
|
||
|
|
|
||
|
|
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||
|
|
addFiles(Array.from(e.target.files || []));
|
||
|
|
e.target.value = '';
|
||
|
|
}, [addFiles]);
|
||
|
|
|
||
|
|
const removeFile = (index: number) => {
|
||
|
|
setPendingFiles(prev => prev.filter((_, i) => i !== index));
|
||
|
|
};
|
||
|
|
|
||
|
|
const uploadFiles = async () => {
|
||
|
|
const toUpload = pendingFiles.filter(f => f.status === 'pending');
|
||
|
|
if (toUpload.length === 0) return;
|
||
|
|
|
||
|
|
setIsUploading(true);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const uploadFn = type === 'model'
|
||
|
|
? intakeApi.uploadFiles
|
||
|
|
: intakeApi.uploadContextFiles;
|
||
|
|
|
||
|
|
const response = await uploadFn(draftId, toUpload.map(f => f.file));
|
||
|
|
|
||
|
|
const results = new Map(
|
||
|
|
response.uploaded_files.map(f => [f.name, f.status === 'uploaded'])
|
||
|
|
);
|
||
|
|
|
||
|
|
setPendingFiles(prev => prev.map(f => {
|
||
|
|
if (f.status !== 'pending') return f;
|
||
|
|
const success = results.get(f.file.name);
|
||
|
|
return {
|
||
|
|
...f,
|
||
|
|
status: success ? 'success' : 'error',
|
||
|
|
message: success ? undefined : 'Upload failed',
|
||
|
|
};
|
||
|
|
}));
|
||
|
|
|
||
|
|
setTimeout(() => {
|
||
|
|
setPendingFiles(prev => prev.filter(f => f.status !== 'success'));
|
||
|
|
onUploadComplete();
|
||
|
|
}, 1000);
|
||
|
|
|
||
|
|
} catch (err) {
|
||
|
|
setPendingFiles(prev => prev.map(f =>
|
||
|
|
f.status === 'pending'
|
||
|
|
? { ...f, status: 'error', message: 'Upload failed' }
|
||
|
|
: f
|
||
|
|
));
|
||
|
|
} finally {
|
||
|
|
setIsUploading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Auto-upload when files are added
|
||
|
|
React.useEffect(() => {
|
||
|
|
const pending = pendingFiles.filter(f => f.status === 'pending');
|
||
|
|
if (pending.length > 0 && !isUploading) {
|
||
|
|
uploadFiles();
|
||
|
|
}
|
||
|
|
}, [pendingFiles, isUploading]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-2">
|
||
|
|
{/* Drop Zone */}
|
||
|
|
<div
|
||
|
|
onDragEnter={handleDragEnter}
|
||
|
|
onDragLeave={handleDragLeave}
|
||
|
|
onDragOver={handleDragOver}
|
||
|
|
onDrop={handleDrop}
|
||
|
|
onClick={() => fileInputRef.current?.click()}
|
||
|
|
className={`
|
||
|
|
relative border-2 border-dashed rounded-lg p-4 cursor-pointer
|
||
|
|
transition-all duration-200 text-center
|
||
|
|
${isDragging
|
||
|
|
? 'border-primary-400 bg-primary-400/5'
|
||
|
|
: 'border-dark-600 hover:border-primary-400/50 hover:bg-white/5'
|
||
|
|
}
|
||
|
|
`}
|
||
|
|
>
|
||
|
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center mx-auto mb-2
|
||
|
|
${isDragging ? 'bg-primary-400/20 text-primary-400' : 'bg-dark-700 text-dark-400'}`}>
|
||
|
|
<Upload className="w-4 h-4" />
|
||
|
|
</div>
|
||
|
|
<p className="text-sm text-dark-300">
|
||
|
|
{isDragging ? 'Drop files here' : 'Drop or click to add'}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-dark-500 mt-1">
|
||
|
|
{validExtensions.join(', ')}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Existing Files */}
|
||
|
|
{files.length > 0 && (
|
||
|
|
<div className="space-y-1">
|
||
|
|
{files.map((name, i) => (
|
||
|
|
<div
|
||
|
|
key={i}
|
||
|
|
className="flex items-center gap-2 px-2 py-1.5 rounded bg-dark-700/50 text-sm"
|
||
|
|
>
|
||
|
|
<File className="w-3.5 h-3.5 text-dark-400" />
|
||
|
|
<span className="text-dark-200 truncate flex-1">{name}</span>
|
||
|
|
<CheckCircle className="w-3.5 h-3.5 text-green-400" />
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Pending Files */}
|
||
|
|
{pendingFiles.length > 0 && (
|
||
|
|
<div className="space-y-1">
|
||
|
|
{pendingFiles.map((f, i) => (
|
||
|
|
<div
|
||
|
|
key={i}
|
||
|
|
className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm
|
||
|
|
${f.status === 'error' ? 'bg-red-500/10' :
|
||
|
|
f.status === 'success' ? 'bg-green-500/10' : 'bg-dark-700'}`}
|
||
|
|
>
|
||
|
|
{f.status === 'pending' && <Loader2 className="w-3.5 h-3.5 text-primary-400 animate-spin" />}
|
||
|
|
{f.status === 'uploading' && <Loader2 className="w-3.5 h-3.5 text-primary-400 animate-spin" />}
|
||
|
|
{f.status === 'success' && <CheckCircle className="w-3.5 h-3.5 text-green-400" />}
|
||
|
|
{f.status === 'error' && <AlertCircle className="w-3.5 h-3.5 text-red-400" />}
|
||
|
|
<span className={`truncate flex-1 ${f.status === 'error' ? 'text-red-400' : 'text-dark-200'}`}>
|
||
|
|
{f.file.name}
|
||
|
|
</span>
|
||
|
|
{f.message && (
|
||
|
|
<span className="text-xs text-red-400">({f.message})</span>
|
||
|
|
)}
|
||
|
|
{f.status === 'pending' && (
|
||
|
|
<button onClick={(e) => { e.stopPropagation(); removeFile(i); }} className="p-0.5 hover:bg-white/10 rounded">
|
||
|
|
<X className="w-3 h-3 text-dark-400" />
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<input
|
||
|
|
ref={fileInputRef}
|
||
|
|
type="file"
|
||
|
|
multiple
|
||
|
|
accept={validExtensions.join(',')}
|
||
|
|
onChange={handleFileSelect}
|
||
|
|
className="hidden"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export default StudioDropZone;
|