import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { X, Folder, File, ChevronLeft, Download, Trash2, Loader2, HardDrive, RefreshCw, Film, FileBox, FileText, Image, Search, ArrowUpDown, } from 'lucide-react'; import { api } from '../api/client'; import { Button } from './Button'; import { ConfirmModal } from './ConfirmModal'; import { useToast } from '../contexts/ToastContext'; interface FileManagerModalProps { printerId: number; printerName: string; onClose: () => void; } 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 { showToast } = useToast(); const queryClient = useQueryClient(); const [currentPath, setCurrentPath] = useState('/'); const [selectedFile, setSelectedFile] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [fileToDelete, setFileToDelete] = useState(null); const [sortBy, setSortBy] = useState('name-asc'); // 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: (path: string) => api.deletePrinterFile(printerId, path), onSuccess: (_, path) => { showToast(`Deleted: ${path.split('/').pop()}`); queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] }); setSelectedFile(null); }, onError: (error: Error) => { showToast(`Delete failed: ${error.message}`, 'error'); }, }); const navigateToFolder = (path: string) => { setCurrentPath(path); setSelectedFile(null); }; const navigateUp = () => { if (currentPath === '/') return; const parts = currentPath.split('/').filter(Boolean); parts.pop(); setCurrentPath(parts.length ? '/' + parts.join('/') : '/'); setSelectedFile(null); }; const handleDownload = (path: string) => { window.open(api.getPrinterFileDownloadUrl(printerId, path), '_blank'); }; const handleDelete = (path: string) => { setFileToDelete(path); }; // 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 */}

File Manager

{printerName}

{/* Storage info */} {storageData && (storageData.used_bytes != null || storageData.free_bytes != null) && (
{storageData.used_bytes != null && ( Used: {formatStorageSize(storageData.used_bytes)} )} {storageData.used_bytes != null && storageData.free_bytes != null && ( | )} {storageData.free_bytes != null && ( Free: {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 = selectedFile === file.path; return (
{ if (file.is_directory) { navigateToFolder(file.path); } else { setSelectedFile(isSelected ? null : file.path); } }} > {file.name} {!file.is_directory && ( {formatFileSize(file.size)} )} {file.is_directory && ( )}
); })}
)}
{/* Action bar */}
{searchQuery ? `${data?.files?.filter(f => f.name.toLowerCase().includes(searchQuery.toLowerCase())).length || 0} of ${data?.files?.length || 0} items` : `${data?.files?.length || 0} items` }
{/* Delete Confirmation Modal */} {fileToDelete && ( { deleteMutation.mutate(fileToDelete); setFileToDelete(null); }} onCancel={() => setFileToDelete(null)} /> )}
); }