import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { X, Folder, File, ChevronLeft, Download, Trash2, Loader2, HardDrive, RefreshCw, Film, FileBox, FileText, Image, Search, ArrowUpDown, CheckSquare, Square, MinusSquare, Box, } from 'lucide-react'; import { api } from '../api/client'; import { Button } from './Button'; import { ConfirmModal } from './ConfirmModal'; import { ModelViewer } from './ModelViewer'; import { GcodeViewer } from './GcodeViewer'; import type { PlateMetadata } from '../types/plates'; import { useToast } from '../contexts/ToastContext'; interface FileManagerModalProps { printerId: number; printerName: string; onClose: () => void; } type PrinterViewerTab = '3d' | 'gcode'; interface PrinterFileViewerModalProps { printerId: number; filePath: string; filename: string; onClose: () => void; } function PrinterFileViewerModal({ printerId, filePath, filename, onClose }: PrinterFileViewerModalProps) { const [activeTab, setActiveTab] = useState(null); const [plates, setPlates] = useState([]); const [platesLoading, setPlatesLoading] = useState(false); const [selectedPlateId, setSelectedPlateId] = useState(null); const ext = filename.toLowerCase().split('.').pop() || ''; const hasModel = ext === '3mf' || ext === 'stl'; const hasGcode = ext === 'gcode' || ext === '3mf'; useEffect(() => { setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null); }, [hasModel, hasGcode]); useEffect(() => { setPlates([]); setSelectedPlateId(null); if (!hasModel) return; setPlatesLoading(true); api.getPrinterFilePlates(printerId, filePath) .then((data) => setPlates(data.plates || [])) .catch(() => setPlates([])) .finally(() => setPlatesLoading(false)); }, [filePath, hasModel, printerId]); const hasMultiplePlates = plates.length > 1; const selectedPlate = selectedPlateId == null ? null : plates.find((plate) => plate.index === selectedPlateId) ?? null; return (
e.stopPropagation()} >

{filename}

{activeTab === '3d' && hasModel ? (
{hasMultiplePlates && (
Plates {platesLoading && }
{plates.map((plate) => ( ))}
{selectedPlate && (
Plate {selectedPlate.index} {selectedPlate.print_time_seconds != null && ( ETA {Math.round(selectedPlate.print_time_seconds / 60)} min )} {selectedPlate.filament_used_grams != null && ( {selectedPlate.filament_used_grams.toFixed(1)} g )} {selectedPlate.filaments.length > 0 && ( {selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''} )}
)}
)}
) : activeTab === 'gcode' && hasGcode ? ( ) : (
No preview available for this file
)}
); } function formatFileSize(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; } function formatStorageSize(bytes: number): string { if (bytes === 0) return '0 GB'; const gb = bytes / (1024 * 1024 * 1024); if (gb >= 1) { return `${gb.toFixed(1)} GB`; } const mb = bytes / (1024 * 1024); return `${mb.toFixed(0)} MB`; } function getFileIcon(filename: string, isDirectory: boolean) { if (isDirectory) return Folder; const ext = filename.toLowerCase().split('.').pop() || ''; switch (ext) { case '3mf': return FileBox; case 'gcode': return FileText; case 'mp4': case 'avi': return Film; case 'png': case 'jpg': case 'jpeg': return Image; default: return File; } } type SortOption = 'name-asc' | 'name-desc' | 'size-asc' | 'size-desc' | 'date-asc' | 'date-desc'; const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: 'name-asc', label: 'Name (A-Z)' }, { value: 'name-desc', label: 'Name (Z-A)' }, { value: 'size-asc', label: 'Size (smallest)' }, { value: 'size-desc', label: 'Size (largest)' }, { value: 'date-asc', label: 'Date (oldest)' }, { value: 'date-desc', label: 'Date (newest)' }, ]; export function FileManagerModal({ printerId, printerName, onClose }: FileManagerModalProps) { const { t } = useTranslation(); const { showToast } = useToast(); const queryClient = useQueryClient(); const [currentPath, setCurrentPath] = useState('/'); const [selectedFiles, setSelectedFiles] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(''); const [filesToDelete, setFilesToDelete] = useState([]); const [sortBy, setSortBy] = useState('name-asc'); const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null); const [viewerFile, setViewerFile] = useState<{ path: string; name: string } | null>(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 { data, isLoading, refetch } = useQuery({ queryKey: ['printerFiles', printerId, currentPath], queryFn: () => api.getPrinterFiles(printerId, currentPath), }); const { data: storageData } = useQuery({ queryKey: ['printerStorage', printerId], queryFn: () => api.getPrinterStorage(printerId), staleTime: 30000, // Cache for 30 seconds }); const deleteMutation = useMutation({ mutationFn: async (paths: string[]) => { // Delete files one by one for (const path of paths) { await api.deletePrinterFile(printerId, path); } }, onSuccess: () => { showToast(t('printerFiles.toast.filesDeleted', { count: filesToDelete.length })); queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] }); setSelectedFiles(new Set()); setFilesToDelete([]); }, onError: (error: Error) => { showToast(t('printerFiles.toast.deleteFailed', { error: error.message }), 'error'); }, }); const navigateToFolder = (path: string) => { setCurrentPath(path); setSelectedFiles(new Set()); }; const navigateUp = () => { if (currentPath === '/') return; const parts = currentPath.split('/').filter(Boolean); parts.pop(); setCurrentPath(parts.length ? '/' + parts.join('/') : '/'); setSelectedFiles(new Set()); }; const toggleFileSelection = (path: string, e: React.MouseEvent) => { e.stopPropagation(); setSelectedFiles(prev => { const next = new Set(prev); if (next.has(path)) { next.delete(path); } else { next.add(path); } return next; }); }; const selectAllFiles = () => { if (!data?.files) return; const filePaths = data.files .filter(f => !f.is_directory && (!searchQuery || f.name.toLowerCase().includes(searchQuery.toLowerCase()))) .map(f => f.path); setSelectedFiles(new Set(filePaths)); }; const deselectAllFiles = () => { setSelectedFiles(new Set()); }; const handleDownload = async () => { if (selectedFiles.size === 0) return; const paths = Array.from(selectedFiles); if (paths.length === 1) { // Single file - direct download with auth api.downloadPrinterFile(printerId, paths[0]).catch((err) => { console.error('Printer file download failed:', err); }); setSelectedFiles(new Set()); return; } // Multiple files - download as ZIP setDownloadProgress({ current: 0, total: paths.length }); try { const blob = await api.downloadPrinterFilesAsZip(printerId, paths); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${printerName.replace(/[^a-zA-Z0-9]/g, '_')}-files.zip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showToast(`Downloaded ${paths.length} files as ZIP`); setSelectedFiles(new Set()); } catch (error) { showToast(`Download failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error'); } finally { setDownloadProgress(null); } }; const handleDelete = () => { if (selectedFiles.size === 0) return; setFilesToDelete(Array.from(selectedFiles)); }; // Quick navigation buttons for common directories const quickDirs = [ { path: '/', label: 'Root' }, { path: '/cache', label: 'Cache' }, { path: '/model', label: 'Models' }, { path: '/timelapse', label: 'Timelapse' }, ]; return (
e.stopPropagation()} > {/* Header */}

{t('printerFiles.title')}

{printerName}

{/* Storage info */} {storageData && (storageData.used_bytes != null || storageData.free_bytes != null) && (
{storageData.used_bytes != null && ( {t('printerFiles.storageUsed')} {formatStorageSize(storageData.used_bytes)} )} {storageData.used_bytes != null && storageData.free_bytes != null && ( | )} {storageData.free_bytes != null && ( {t('printerFiles.storageFree')} {formatStorageSize(storageData.free_bytes)} )}
)}
{/* Quick Navigation */}
{quickDirs.map((dir) => ( ))}
setSearchQuery(e.target.value)} className="w-40 pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none" />
{/* Path breadcrumb */}
{currentPath}
{/* File list */}
{isLoading ? (
) : !data?.files?.length ? (
No files in this directory
) : (
{/* Filter and sort: directories first, then files with selected sort */} {[...data.files] .filter((file) => !searchQuery || file.name.toLowerCase().includes(searchQuery.toLowerCase()) ) .sort((a, b) => { // Directories always first if (a.is_directory && !b.is_directory) return -1; if (!a.is_directory && b.is_directory) return 1; // Apply selected sort within same type switch (sortBy) { case 'name-asc': return a.name.localeCompare(b.name); case 'name-desc': return b.name.localeCompare(a.name); case 'size-asc': return a.size - b.size; case 'size-desc': return b.size - a.size; case 'date-asc': { const aTime = a.mtime ? new Date(a.mtime).getTime() : 0; const bTime = b.mtime ? new Date(b.mtime).getTime() : 0; return aTime - bTime; } case 'date-desc': { const aTime = a.mtime ? new Date(a.mtime).getTime() : 0; const bTime = b.mtime ? new Date(b.mtime).getTime() : 0; return bTime - aTime; } default: return a.name.localeCompare(b.name); } }) .map((file) => { const FileIcon = getFileIcon(file.name, file.is_directory); const isSelected = selectedFiles.has(file.path); return (
{ if (file.is_directory) { navigateToFolder(file.path); } }} > {/* Checkbox for files only */} {!file.is_directory ? ( ) : null} {file.name} {!file.is_directory && (
{formatFileSize(file.size)} {(file.name.toLowerCase().endsWith('.3mf') || file.name.toLowerCase().endsWith('.gcode') || file.name.toLowerCase().endsWith('.stl')) && ( )}
)} {file.is_directory && ( )}
); })}
)}
{/* Action bar */}
{selectedFiles.size > 0 ? `${selectedFiles.size} selected` : searchQuery ? `${data?.files?.filter(f => f.name.toLowerCase().includes(searchQuery.toLowerCase())).length || 0} of ${data?.files?.length || 0} items` : `${data?.files?.length || 0} items` }
{/* Select All / Deselect All */} {data?.files?.some(f => !f.is_directory) && (
{selectedFiles.size > 0 ? ( ) : ( )}
)}
{/* Delete Confirmation Modal */} {filesToDelete.length > 0 && ( 1 ? t('printerFiles.deleteFiles', { count: filesToDelete.length }) : t('fileManager.deleteFile')} message={ filesToDelete.length > 1 ? t('printerFiles.deleteFilesConfirm', { count: filesToDelete.length }) : t('printerFiles.deleteFileConfirm', { name: filesToDelete[0].split('/').pop() }) } confirmText={t('common.delete')} variant="danger" onConfirm={() => { deleteMutation.mutate(filesToDelete); }} onCancel={() => setFilesToDelete([])} /> )} {viewerFile && ( setViewerFile(null)} /> )}
); }