import { useState, useRef, type DragEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { Upload, X, File, Loader2, CheckCircle, XCircle, Archive as ArchiveIcon, Printer, Image, } from 'lucide-react'; import { api } from '../api/client'; import type { LibraryFileUploadResponse } from '../api/client'; import { Button } from './Button'; interface UploadFile { file: File; status: 'pending' | 'uploading' | 'success' | 'error'; error?: string; isZip?: boolean; is3mf?: boolean; extractedCount?: number; } interface FileUploadModalProps { folderId: number | null; onClose: () => void; onUploadComplete: () => void; /** Called after each file is successfully uploaded with its response data. Return a string to show an error and prevent modal from closing. */ onFileUploaded?: (file: LibraryFileUploadResponse) => string | void; /** When true, automatically uploads the file as soon as it's added and closes the modal */ autoUpload?: boolean; /** Validate files before adding. Return a string to reject with an error message. */ validateFile?: (file: File) => string | undefined; /** Restrict file picker to specific file types (e.g. ".gcode,.gcode.3mf") */ accept?: string; } export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUploaded, autoUpload, validateFile, accept }: FileUploadModalProps) { const { t } = useTranslation(); const [files, setFiles] = useState([]); const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); const [preserveZipStructure, setPreserveZipStructure] = useState(true); const [createFolderFromZip, setCreateFolderFromZip] = useState(false); const [generateStlThumbnails, setGenerateStlThumbnails] = useState(true); const [uploadError, setUploadError] = useState(null); const fileInputRef = useRef(null); const handleDragOver = (e: DragEvent) => { e.preventDefault(); setIsDragging(true); }; const handleDragLeave = (e: DragEvent) => { e.preventDefault(); setIsDragging(false); }; const handleDrop = (e: DragEvent) => { e.preventDefault(); setIsDragging(false); addFiles(Array.from(e.dataTransfer.files)); }; const handleFileSelect = (e: React.ChangeEvent) => { if (e.target.files) { addFiles(Array.from(e.target.files)); } }; const updateFileStatus = (file: File, update: Partial) => { setFiles((prev) => prev.map((f) => (f.file === file ? { ...f, ...update } : f))); }; const uploadFiles = async (filesToUpload: UploadFile[]) => { setIsUploading(true); for (const uf of filesToUpload) { if (uf.status !== 'pending') continue; updateFileStatus(uf.file, { status: 'uploading' }); try { if (uf.isZip) { const result = await api.extractZipFile(uf.file, folderId, preserveZipStructure, createFolderFromZip, generateStlThumbnails); updateFileStatus(uf.file, { status: result.errors.length > 0 && result.extracted === 0 ? 'error' : 'success', extractedCount: result.extracted, error: result.errors.length > 0 ? t('fileManager.zipFilesFailed', '{{count}} files failed', { count: result.errors.length }) : undefined, }); } else { const result = await api.uploadLibraryFile(uf.file, folderId, generateStlThumbnails); updateFileStatus(uf.file, { status: 'success' }); const error = onFileUploaded?.(result); if (error) { setUploadError(error); setFiles([]); setIsUploading(false); return; } } } catch (err) { updateFileStatus(uf.file, { status: 'error', error: err instanceof Error ? err.message : t('fileManager.uploadFailed', 'Upload failed'), }); } } setIsUploading(false); onUploadComplete(); // #1401: don't auto-close if any file ended with an error — the user // needs to see the rejection message (e.g. "raw .gcode upload"), not // have the modal vanish before they can read it. Closing happens via // the X / Close button instead, after the user has seen what failed. setFiles((prev) => { const anyFailed = prev.some((f) => f.status === 'error'); if (!anyFailed) { onClose(); } return prev; }); }; const addFiles = (newFiles: File[]) => { setUploadError(null); if (validateFile) { for (const file of newFiles) { const error = validateFile(file); if (error) { setUploadError(error); return; } } } const toUpload: UploadFile[] = newFiles.map((file) => ({ file, status: 'pending' as const, isZip: file.name.toLowerCase().endsWith('.zip'), is3mf: file.name.toLowerCase().endsWith('.3mf'), })); setFiles((prev) => [...prev, ...toUpload]); if (autoUpload && newFiles.length > 0) { uploadFiles(toUpload); } }; const removeFile = (index: number) => { setFiles((prev) => prev.filter((_, i) => i !== index)); }; const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending'); const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending'); const has3mfFiles = files.some((f) => f.is3mf && f.status === 'pending'); const pendingCount = files.filter((f) => f.status === 'pending').length; const allDone = files.length > 0 && pendingCount === 0 && !isUploading; return (

{t('fileManager.uploadFiles')}

{/* Drop Zone */}
fileInputRef.current?.click()} className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${ isDragging ? 'border-bambu-green bg-bambu-green/10' : 'border-bambu-dark-tertiary hover:border-bambu-green/50' }`} >

{isDragging ? t('fileManager.dropFilesHere') : t('fileManager.dragDropFiles')}

{t('fileManager.orClickToBrowse')}

{t('fileManager.allFileTypesSupported')}

{/* ZIP Options */} {hasZipFiles && (

{t('fileManager.zipFilesDetected')}

{t('fileManager.zipExtractOptions')}

)} {/* 3MF File Info */} {has3mfFiles && (

{t('fileManager.threemfDetected')}

{t('fileManager.threemfExtractionInfo')}

)} {/* STL Thumbnail Options */} {(hasStlFiles || hasZipFiles) && (

{t('fileManager.stlThumbnailGeneration')}

{hasZipFiles && !hasStlFiles ? t('fileManager.zipMayContainStl') : t('fileManager.thumbnailsCanBeGenerated')}

)} {/* File List */} {files.length > 0 && (
{files.map((uploadFile, index) => (
{uploadFile.isZip ? ( ) : ( )}

{uploadFile.file.name}

{(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB {uploadFile.isZip && uploadFile.status === 'pending' && ( • {t('fileManager.willBeExtracted')} )} {uploadFile.extractedCount !== undefined && ( • {t('fileManager.filesExtracted', { count: uploadFile.extractedCount })} )}

{/* #1401: errors render inline rather than as a hover-only title. The backend's rejection messages explain the actual fix (re-export as .gcode.3mf) — useless if the user can't read them. */} {uploadFile.status === 'error' && uploadFile.error && (

{uploadFile.error}

)}
{uploadFile.status === 'pending' && ( )} {uploadFile.status === 'uploading' && ( )} {uploadFile.status === 'success' && ( )} {uploadFile.status === 'error' && ( )}
))}
)} {/* Compatibility Error */} {uploadError && (

{uploadError}

)}
{!allDone && ( )}
); }