import { useState, useCallback, useRef, useEffect } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; import { api } from '../api/client'; import type { BulkUploadResult } from '../api/client'; import { Card, CardContent } from './Card'; import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; interface FileWithStatus { file: File; status: 'pending' | 'uploading' | 'success' | 'error'; error?: string; archiveId?: number; } interface UploadModalProps { onClose: () => void; initialFiles?: File[]; } export function UploadModal({ onClose, initialFiles }: UploadModalProps) { const queryClient = useQueryClient(); const { showToast } = useToast(); const fileInputRef = useRef(null); const [files, setFiles] = useState(() => initialFiles?.filter(f => f.name.endsWith('.3mf')).map(file => ({ file, status: 'pending' as const })) || [] ); const [isDragging, setIsDragging] = useState(false); const [uploadResult, setUploadResult] = useState(null); // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); const uploadMutation = useMutation({ mutationFn: (filesToUpload: File[]) => api.uploadArchivesBulk(filesToUpload), onSuccess: (result) => { setUploadResult(result); queryClient.invalidateQueries({ queryKey: ['archives'] }); queryClient.invalidateQueries({ queryKey: ['archiveStats'] }); // Update file statuses based on result setFiles((prev) => prev.map((f) => { const success = result.results.find((r) => r.filename === f.file.name); const error = result.errors.find((e) => e.filename === f.file.name); if (success) { return { ...f, status: 'success', archiveId: success.id }; } if (error) { return { ...f, status: 'error', error: error.error }; } return f; }) ); // Show toast if (result.failed === 0) { showToast(`${result.uploaded} file${result.uploaded !== 1 ? 's' : ''} uploaded`); } else if (result.uploaded === 0) { showToast(`Failed to upload ${result.failed} file${result.failed !== 1 ? 's' : ''}`, 'error'); } else { showToast(`${result.uploaded} uploaded, ${result.failed} failed`, 'warning'); } }, onError: () => { setFiles((prev) => prev.map((f) => ({ ...f, status: 'error', error: 'Upload failed' })) ); showToast('Upload failed', 'error'); }, }); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); const droppedFiles = Array.from(e.dataTransfer.files).filter((f) => f.name.endsWith('.3mf') ); if (droppedFiles.length > 0) { setFiles((prev) => [ ...prev, ...droppedFiles.map((file) => ({ file, status: 'pending' as const })), ]); } }, []); const handleFileSelect = useCallback((e: React.ChangeEvent) => { const selectedFiles = Array.from(e.target.files || []).filter((f) => f.name.endsWith('.3mf') ); if (selectedFiles.length > 0) { setFiles((prev) => [ ...prev, ...selectedFiles.map((file) => ({ file, status: 'pending' as const })), ]); } // Reset input so same file can be selected again if (fileInputRef.current) { fileInputRef.current.value = ''; } }, []); const removeFile = useCallback((index: number) => { setFiles((prev) => prev.filter((_, i) => i !== index)); }, []); const handleUpload = () => { if (files.length === 0) return; const pendingFiles = files.filter((f) => f.status === 'pending'); if (pendingFiles.length === 0) return; setFiles((prev) => prev.map((f) => f.status === 'pending' ? { ...f, status: 'uploading' } : f ) ); uploadMutation.mutate(pendingFiles.map((f) => f.file)); }; const pendingCount = files.filter((f) => f.status === 'pending').length; const isUploading = uploadMutation.isPending; return (
{/* Header */}

Upload 3MF Files

{/* Drop Zone */}

Drag & drop .3mf files here

or

{/* Info about printer model extraction */}

The printer model will be automatically extracted from the 3MF file metadata.

{/* File List */} {files.length > 0 && (
{files.map((f, index) => (
{f.file.name} {(f.file.size / (1024 * 1024)).toFixed(1)} MB {f.status === 'pending' && ( )} {f.status === 'uploading' && ( )} {f.status === 'success' && ( )} {f.status === 'error' && (
{f.error}
)}
))}
)} {/* Upload Result Summary */} {uploadResult && (

{uploadResult.uploaded} uploaded {uploadResult.failed > 0 && ( <>, {uploadResult.failed} failed )}

)} {/* Footer */}
{!uploadResult && ( )}
); }