import { useState, useRef, useCallback, useMemo, useEffect } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { FolderOpen, Loader2, Plus, Upload, Trash2, Download, MoreVertical, ChevronRight, FolderPlus, FileBox, Clock, CalendarClock, HardDrive, File, MoveRight, CheckSquare, Square, Layers, LayoutGrid, List, Search, SortAsc, SortDesc, AlertTriangle, Filter, X, Link2, Unlink, Archive as ArchiveIcon, Briefcase, Cog, Play, Printer, Pencil, Image, User, Box, RefreshCw, Lock, FolderSymlink, Tag as TagIcon, } from 'lucide-react'; import { api } from '../api/client'; import type { LibraryFolderTree, LibraryFileListItem, LibraryFolderCreate, LibraryFolderUpdate, ExternalFolderCreate, AppSettings, Archive, Permission, } from '../api/client'; import { Button } from '../components/Button'; import { ConfirmModal } from '../components/ConfirmModal'; import { PrintModal } from '../components/PrintModal'; import { ModelViewerModal } from '../components/ModelViewerModal'; import { SliceModal } from '../components/SliceModal'; import { RunWithPipelineModal } from '../components/RunWithPipelineModal'; import { BulkTagsPickerModal } from '../components/BulkTagsPickerModal'; import { FileUploadModal } from '../components/FileUploadModal'; import { FolderReadmePanel } from '../components/FolderReadmePanel'; import { LibraryTagsModal } from '../components/LibraryTagsModal'; import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal'; import { useToast } from '../contexts/ToastContext'; import { useIsMobile } from '../hooks/useIsMobile'; import { usePageFileDrop } from '../hooks/usePageFileDrop'; import { useAuth } from '../contexts/AuthContext'; import { formatDuration, parseUTCDate, formatDate } from '../utils/date'; import { formatFileSize } from '../utils/file'; type SortField = 'name' | 'date' | 'size' | 'type' | 'prints'; type SortDirection = 'asc' | 'desc'; type TFunction = (key: string, options?: Record) => string; // New Folder Modal interface NewFolderModalProps { parentId: number | null; onClose: () => void; onSave: (data: LibraryFolderCreate) => void; isLoading: boolean; t: TFunction; } function NewFolderModal({ parentId, onClose, onSave, isLoading, t }: NewFolderModalProps) { const [name, setName] = useState(''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSave({ name: name.trim(), parent_id: parentId }); }; return (

{t('fileManager.newFolder')}

setName(e.target.value)} className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" placeholder={t('fileManager.folderNamePlaceholder')} autoFocus required />
); } // External Folder Modal interface ExternalFolderModalProps { onClose: () => void; onSave: (data: ExternalFolderCreate) => void; isLoading: boolean; t: TFunction; } function ExternalFolderModal({ onClose, onSave, isLoading, t }: ExternalFolderModalProps) { const [name, setName] = useState(''); const [path, setPath] = useState(''); const [readonly, setReadonly] = useState(true); const [showHidden, setShowHidden] = useState(false); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSave({ name: name.trim(), external_path: path.trim(), readonly, show_hidden: showHidden, }); }; return (

{t('fileManager.linkExternalFolder')}

{t('fileManager.linkExternalFolderDescription')}

setName(e.target.value)} className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" placeholder={t('fileManager.externalFolderNamePlaceholder')} autoFocus required />
setPath(e.target.value)} className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green font-mono text-sm" placeholder="/mnt/nas/3d-prints" required />

{t('fileManager.externalPathHelp')}

); } // FAT32/exFAT-illegal chars rejected by Bambu Studio (#1540). Mirrors the // backend validator in backend/app/utils/filename.py — keep in sync. const INVALID_FILENAME_CHARS = '<>:"/\\|?*'; function findInvalidFilenameChar(name: string): string | null { for (const ch of name) { if (INVALID_FILENAME_CHARS.includes(ch)) return ch; if (ch.charCodeAt(0) < 0x20) return ch; } return null; } // Rename Modal interface RenameModalProps { type: 'file' | 'folder'; currentName: string; onClose: () => void; onSave: (newName: string) => void; isLoading: boolean; t: TFunction; } function RenameModal({ type, currentName, onClose, onSave, isLoading, t }: RenameModalProps) { // For files, separate the extension so users can only edit the base name // Handle compound extensions like .gcode.3mf const fileExtension = type === 'file' ? (currentName.match(/(\.gcode\.3mf|\.3mf|\.gcode)$/i)?.[1] ?? '') : ''; const baseName = type === 'file' && fileExtension ? currentName.slice(0, -fileExtension.length) : currentName; const [name, setName] = useState(baseName); const invalidChar = type === 'file' ? findInvalidFilenameChar(name) : null; const filenameError = invalidChar ? t('fileManager.invalidFilenameChar', { char: invalidChar }) : null; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (filenameError) return; const fullName = type === 'file' ? name.trim() + fileExtension : name.trim(); if (name.trim() && fullName !== currentName) { onSave(fullName); } }; return (

{type === 'file' ? t('fileManager.renameFile') : t('fileManager.renameFolder')}

setName(e.target.value)} className="flex-1 bg-transparent px-3 py-2 text-white placeholder-bambu-gray focus:outline-none min-w-0" autoFocus required /> {fileExtension && ( {fileExtension} )}
{filenameError && (

{filenameError}

)}
); } // Move Files Modal interface MoveFilesModalProps { folders: LibraryFolderTree[]; selectedFiles: number[]; currentFolderId: number | null; onClose: () => void; onMove: (folderId: number | null) => void; isLoading: boolean; t: TFunction; } function MoveFilesModal({ folders, selectedFiles, currentFolderId, onClose, onMove, isLoading, t }: MoveFilesModalProps) { const [targetFolder, setTargetFolder] = useState(null); const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number | null; name: string; depth: number }[] => { const result: { id: number | null; name: string; depth: number }[] = []; for (const item of items) { result.push({ id: item.id, name: item.name, depth }); if (item.children.length > 0) { result.push(...flattenFolders(item.children, depth + 1)); } } return result; }; const flatFolders = [{ id: null, name: t('fileManager.rootNoFolder'), depth: 0 }, ...flattenFolders(folders)]; return (

{t('fileManager.moveFiles', { count: selectedFiles.length })}

{flatFolders.map((folder) => ( ))}
); } // Link Folder Modal interface LinkFolderModalProps { folder: LibraryFolderTree; onClose: () => void; onLink: (update: LibraryFolderUpdate) => void; isLoading: boolean; t: TFunction; } function LinkFolderModal({ folder, onClose, onLink, isLoading, t }: LinkFolderModalProps) { const [linkType, setLinkType] = useState<'project' | 'archive'>('project'); const [selectedId, setSelectedId] = useState( folder.project_id || folder.archive_id || null ); // Initialize linkType based on existing link useState(() => { if (folder.archive_id) setLinkType('archive'); }); const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: () => api.getProjects(), select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)), }); const { data: archives } = useQuery({ queryKey: ['archives-for-link'], queryFn: () => api.getArchives(undefined, undefined, 100), }); const handleSave = () => { if (linkType === 'project') { onLink({ project_id: selectedId, archive_id: 0, // Unlink archive }); } else { onLink({ project_id: 0, // Unlink project archive_id: selectedId, }); } }; const handleUnlink = () => { onLink({ project_id: 0, archive_id: 0, }); }; const isLinked = folder.project_id || folder.archive_id; return (

{t('fileManager.linkFolder')}

{t('fileManager.linkFolderDescription', { name: folder.name })}

{/* Link type selector */}
{/* Selection list */}
{linkType === 'project' ? ( projects && projects.length > 0 ? ( projects.map((project) => ( )) ) : (

{t('fileManager.noProjectsFound')}

) ) : ( archives && archives.length > 0 ? ( archives.map((archive: Archive) => ( )) ) : (

{t('fileManager.noArchivesFound')}

) )}
{isLinked && ( )}
); } // Folder Tree Item interface FolderTreeItemProps { folder: LibraryFolderTree; selectedFolderId: number | null; onSelect: (id: number | null) => void; onDelete: (id: number) => void; onLink: (folder: LibraryFolderTree) => void; onRename: (folder: LibraryFolderTree) => void; depth?: number; wrapNames?: boolean; defaultExpanded?: boolean; showModified?: boolean; hasPermission: (permission: Permission) => boolean; t: TFunction; } function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, defaultExpanded = true, showModified = false, hasPermission, t }: FolderTreeItemProps) { const [expanded, setExpanded] = useState(defaultExpanded); const [showActions, setShowActions] = useState(false); const hasChildren = folder.children.length > 0; const isLinked = folder.project_id || folder.archive_id; const isExternal = folder.is_external; // #1781: users with only library:delete_own may delete empty, unlinked, // non-external folders. The backend enforces the same rule and additionally // counts trashed files (invisible here), so a 403 can still come back. const canDeleteFolder = hasPermission('library:delete_all') || (hasPermission('library:delete_own') && folder.file_count === 0 && !hasChildren && !isExternal && !isLinked); const deleteDisabledTooltip = canDeleteFolder ? undefined : hasPermission('library:delete_own') && !isExternal && !isLinked ? t('fileManager.onlyEmptyFoldersDeletable') : t('fileManager.noPermissionDeleteFolder'); return (
onSelect(folder.id)} > {hasChildren ? ( ) : (
)} {isExternal ? ( ) : ( )}
{folder.name} {/* #2680 follow-up: the same toolbar toggle that shows dates on file cards also shows them here. This is `latest_activity_at` — the newest timestamp among the folder itself, its files and its subfolders (the value "sort by recent activity" orders on) — not the folder's own on-disk mtime, hence the distinct label. */} {showModified && folder.latest_activity_at && ( {formatDate(folder.latest_activity_at)} )}
{/* Link indicator - clickable to change link */} {isLinked && ( )} {/* Read-only indicator for external folders */} {isExternal && folder.external_readonly && ( )} {folder.file_count > 0 && ( {folder.file_count} )} {/* Quick link button - always visible for unlinked folders */} {!isLinked && !isExternal && ( )}
e.stopPropagation()}>
{showActions && ( <>
setShowActions(false)} />
)}
{hasChildren && expanded && (
{folder.children.map((child) => ( ))}
)}
); } // Helper to check if a file is sliced (printable) function isSlicedFilename(filename: string): boolean { const lower = filename.toLowerCase(); return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf'); } // Files that can be fed to the slicer sidecar (model geometry inputs). // Excludes .gcode.* (already sliced) and any other non-model formats. function isSliceableFilename(filename: string): boolean { const lower = filename.toLowerCase(); if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false; return lower.endsWith('.stl') || lower.endsWith('.3mf') || lower.endsWith('.step') || lower.endsWith('.stp'); } // File Card interface FileCardProps { file: LibraryFileListItem; isSelected: boolean; isMobile: boolean; onSelect: (id: number) => void; onDelete: (id: number) => void; onDownload: (id: number) => void; onPrint?: (file: LibraryFileListItem) => void; onSlice?: (file: LibraryFileListItem) => void; onRunPipeline?: (file: LibraryFileListItem) => void; useSlicerApi?: boolean; onPreview3d?: (file: LibraryFileListItem) => void; onRename?: (file: LibraryFileListItem) => void; onGenerateThumbnail?: (file: LibraryFileListItem) => void; onTagClick?: (tagId: number) => void; thumbnailVersion?: number; hasPermission: (permission: Permission) => boolean; canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean; authEnabled: boolean; showModified: boolean; t: TFunction; } function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onRunPipeline, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) { const [showActions, setShowActions] = useState(false); return (
onSelect(file.id)} > {/* Thumbnail */}
{file.thumbnail_path ? ( {file.filename} ) : ( )} {/* File type badge */}
{file.file_type.toUpperCase()}
{/* Info */}

{file.print_name || file.filename}

{formatFileSize(file.file_size)} {file.print_time_seconds && ( {formatDuration(file.print_time_seconds)} )}
{file.sliced_for_model && (
{file.sliced_for_model}
)} {/* Counts the whole group, including members in other folders (#671 / #2570) — printing this file will offer all of them. */} {(file.variant_count ?? 0) > 1 && (
{t('fileManager.variants.badge', { count: file.variant_count })}
)} {file.print_count > 0 && (
{t('fileManager.printedCount', { count: file.print_count })}
)} {authEnabled && file.created_by_username && (
{file.created_by_username}
)} {/* #2680: last-modified date, toggled from the toolbar. Uses the real on-disk mtime when known, else the DB created_at. */} {showModified && (
{formatDate(file.fs_modified_at ?? file.created_at)}
)} {(file.tags?.length ?? 0) > 0 && (
e.stopPropagation()}> {file.tags!.map((tg) => ( ))}
)}
{/* Actions - always visible on mobile, hover on desktop */}
e.stopPropagation()}> {showActions && ( <>
setShowActions(false)} />
{onPrint && isSlicedFilename(file.filename) && ( )} {onSlice && useSlicerApi && isSliceableFilename(file.filename) && ( )} {onRunPipeline && useSlicerApi && isSliceableFilename(file.filename) && ( )} {onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl' || file.file_type === 'gcode.3mf') && ( )} {onRename && ( )} {onGenerateThumbnail && file.file_type === 'stl' && ( )}
)}
{/* Selection checkbox - always visible on mobile, hover on desktop */}
{isSelected &&
}
); } export function FileManagerPage() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission, hasAnyPermission, canModify, authEnabled } = useAuth(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); // Read folder ID from URL query parameter const folderIdFromUrl = searchParams.get('folder'); const initialFolderId = folderIdFromUrl ? parseInt(folderIdFromUrl, 10) : null; // State const [selectedFolderId, setSelectedFolderId] = useState(initialFolderId); // Which top-level pseudo-view the sidebar shows when no specific folder is // selected: "internal" = files in Bambuddy's managed storage, "external" = // combined view across every linked external folder (#1621). Per-folder // selection bypasses this (selectedFolderId !== null disables the filter). const [topLevelView, setTopLevelView] = useState<'internal' | 'external'>('internal'); const [selectedFiles, setSelectedFiles] = useState([]); const [showNewFolderModal, setShowNewFolderModal] = useState(false); const [showExternalFolderModal, setShowExternalFolderModal] = useState(false); const [showMoveModal, setShowMoveModal] = useState(false); const [showUploadModal, setShowUploadModal] = useState(false); const [droppedFiles, setDroppedFiles] = useState([]); const [showPurgeModal, setShowPurgeModal] = useState(false); // Tag UI state (#1268). selectedTagIds is the AND-style filter applied to // the listing; setting it bypasses folder scoping on the server so // "every toy" works regardless of which folder is currently selected. const [showTagsModal, setShowTagsModal] = useState(false); const [showBulkTagsModal, setShowBulkTagsModal] = useState(false); const [selectedTagIds, setSelectedTagIds] = useState([]); const [linkFolder, setLinkFolder] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null); const [printFile, setPrintFile] = useState(null); const [sliceFile, setSliceFile] = useState(null); // Slicer Pipelines (#1425 PR B) — file gets "Run with pipeline" action. const [runPipelineFile, setRunPipelineFile] = useState(null); const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null); const [thumbnailVersions, setThumbnailVersions] = useState>({}); const [viewerFile, setViewerFile] = useState(null); const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => { return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid'; }); const [wrapFolderNames, setWrapFolderNames] = useState(() => { return localStorage.getItem('library-wrap-folders') === 'true'; }); const [collapseFoldersByDefault, setCollapseFoldersByDefault] = useState(() => { return localStorage.getItem('library-collapse-folders') === 'true'; }); // Folder tree sort (#1770). 'name' = alphabetical (the prior behaviour); // 'activity' = most recent file activity inside the folder first. Persisted // independently from the file-side sort so each can be tuned to taste. const [folderSortField, setFolderSortField] = useState<'name' | 'activity'>(() => { const saved = localStorage.getItem('library-folder-sort-field'); return saved === 'activity' ? 'activity' : 'name'; }); const [folderSortDirection, setFolderSortDirection] = useState<'asc' | 'desc'>(() => { const saved = localStorage.getItem('library-folder-sort-direction'); return saved === 'desc' ? 'desc' : 'asc'; }); // Resizable sidebar state const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem('library-sidebar-width'); return saved ? parseInt(saved, 10) : 256; // Default w-64 = 256px }); const [isResizing, setIsResizing] = useState(false); const sidebarRef = useRef(null); // Handle sidebar resize useEffect(() => { if (!isResizing) return; // Prevent text selection during resize document.body.style.userSelect = 'none'; document.body.style.cursor = 'col-resize'; const handleMouseMove = (e: MouseEvent) => { if (!sidebarRef.current) return; const containerRect = sidebarRef.current.parentElement?.getBoundingClientRect(); if (!containerRect) return; // Calculate new width based on mouse position relative to container const newWidth = e.clientX - containerRect.left; // Clamp between 200px and 500px const clampedWidth = Math.min(500, Math.max(200, newWidth)); setSidebarWidth(clampedWidth); }; const handleMouseUp = () => { setIsResizing(false); document.body.style.userSelect = ''; document.body.style.cursor = ''; // Save to localStorage localStorage.setItem('library-sidebar-width', String(sidebarWidth)); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.body.style.userSelect = ''; document.body.style.cursor = ''; }; }, [isResizing, sidebarWidth]); // Filter and sort state (persist sort preferences to localStorage) const [searchQuery, setSearchQuery] = useState(''); const [filterType, setFilterType] = useState('all'); const [filterUsername, setFilterUsername] = useState(''); const [sortField, setSortField] = useState(() => { const saved = localStorage.getItem('library-sort-field'); return (saved as SortField) || 'name'; }); const [sortDirection, setSortDirection] = useState(() => { const saved = localStorage.getItem('library-sort-direction'); return (saved as SortDirection) || 'asc'; }); // Show/hide the last-modified date on each file card (#2680). Persisted. const [showModified, setShowModified] = useState( () => localStorage.getItem('library-show-modified') === 'true' ); // Mobile detection for touch-friendly UI const isMobile = useIsMobile(); // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page) useEffect(() => { const folderParam = searchParams.get('folder'); if (folderParam) { const newFolderId = parseInt(folderParam, 10); setSelectedFolderId(newFolderId); } }, [searchParams]); // Queries const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: () => api.getSettings() as Promise, }); const { data: folders, isLoading: foldersLoading } = useQuery({ queryKey: ['library-folders'], queryFn: () => api.getLibraryFolders(), }); // Recursive folder tree sort (#1770). Applies the same comparator to the // top-level list AND to each level of `children`, so sort order is uniform // at every depth of nesting. When sorting by activity, the comparator falls // back to a created-at fallback for folders with no files (`latest_activity_at` // is null) so they stay grouped at the end / start of the bucket instead of // randomly interspersed. const sortedFolders = useMemo(() => { if (!folders) return folders; const sortLevel = (items: LibraryFolderTree[]): LibraryFolderTree[] => { const sorted = [...items].sort((a, b) => { let comparison = 0; if (folderSortField === 'name') { comparison = a.name.localeCompare(b.name); } else { // activity: newest first on 'desc', oldest first on 'asc'. // Folders with no activity timestamp sort to the end regardless // of direction so an empty folder doesn't elbow a recently-used one. const aTs = a.latest_activity_at ? new Date(a.latest_activity_at).getTime() : null; const bTs = b.latest_activity_at ? new Date(b.latest_activity_at).getTime() : null; if (aTs === null && bTs === null) { comparison = a.name.localeCompare(b.name); } else if (aTs === null) { return 1; } else if (bTs === null) { return -1; } else { comparison = aTs - bTs; } } return folderSortDirection === 'asc' ? comparison : -comparison; }); return sorted.map((f) => ({ ...f, children: sortLevel(f.children) })); }; return sortLevel(folders); }, [folders, folderSortField, folderSortDirection]); // Trash count for the header badge (#1008). Empty/error are silently treated // as zero so a broken trash endpoint doesn't break the File Manager. const { data: trashCount } = useQuery({ queryKey: ['library-trash-count'], queryFn: async () => { try { const res = await api.listLibraryTrash(1, 0); return res.total; } catch { return 0; } }, staleTime: 30_000, }); // #1268: when a folder is selected and the user has typed a search query, // ask the server to expand the result to every descendant folder so the // client-side filter can match files in subfolders too. Without this the // listing is just the immediate children and "robot.3mf" two levels deep // is invisible from the parent. Only kicks in for folder-scoped views — // root and the internal/external pseudo-nodes already return the union. const searchExpandsSubfolders = selectedFolderId !== null && searchQuery.trim().length > 0; // The tag filter overrides folder scoping server-side (#1268 design call), // so the FE query key includes it as a peer of folder/topLevelView. Sorted // so the cache hits regardless of the order tags were toggled. const tagFilterKey = useMemo(() => [...selectedTagIds].sort((a, b) => a - b), [selectedTagIds]); // Tag catalog — needed to resolve names for the active-filter chip bar. // Cheap query, shared with LibraryTagsModal / BulkTagsPickerModal via the // same queryKey so they all invalidate together on tag CRUD. const { data: tagCatalog = [] } = useQuery({ queryKey: ['library-tags'], queryFn: api.getLibraryTags, }); const tagsById = useMemo(() => { const map = new Map(); for (const t of tagCatalog) map.set(t.id, t.name); return map; }, [tagCatalog]); // Prune the active filter when a tag is removed from the catalog so the // listing never stalls on a phantom id. Skipped while the catalog query is // still settling (empty array on first paint) — otherwise the user's filter // gets cleared the moment the page mounts. useEffect(() => { if (tagCatalog.length === 0) return; setSelectedTagIds((prev) => { const next = prev.filter((id) => tagsById.has(id)); return next.length === prev.length ? prev : next; }); }, [tagCatalog.length, tagsById]); const toggleTagFilter = useCallback((tagId: number) => { setSelectedTagIds((prev) => prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId], ); }, []); const { data: files, isLoading: filesLoading } = useQuery({ queryKey: ['library-files', selectedFolderId, topLevelView, searchExpandsSubfolders, tagFilterKey], // When a specific folder is selected we list its contents directly; when // no folder is selected the topLevelView pseudo-node decides whether the // server scopes the result to internal-managed-storage files or to the // union of every external folder (#1621). include_root stays false so the // listing still descends into subfolders (regression guard from #1499). queryFn: () => api.getLibraryFiles( selectedFolderId, false, undefined, selectedFolderId === null ? topLevelView : undefined, searchExpandsSubfolders, tagFilterKey, ), }); const { data: stats } = useQuery({ queryKey: ['library-stats'], queryFn: () => api.getLibraryStats(), }); // Get users for the username filter autocomplete const { data: users } = useQuery({ queryKey: ['users'], queryFn: () => api.getUsers(), }); // Get unique file types for filter dropdown const fileTypes = useMemo(() => { if (!files) return []; const types = new Set(files.map((f) => f.file_type)); return Array.from(types).sort(); }, [files]); // Filter and sort files const filteredAndSortedFiles = useMemo(() => { if (!files) return []; let result = [...files]; // Apply search filter if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); result = result.filter( (f) => f.filename.toLowerCase().includes(query) || (f.print_name && f.print_name.toLowerCase().includes(query)) ); } // Apply type filter if (filterType !== 'all') { result = result.filter((f) => f.file_type === filterType); } // Apply username filter if (filterUsername.trim()) { const query = filterUsername.toLowerCase(); result = result.filter( (f) => f.created_by_username && f.created_by_username.toLowerCase().includes(query) ); } // Apply sorting result.sort((a, b) => { let comparison = 0; switch (sortField) { case 'name': comparison = (a.print_name || a.filename).localeCompare(b.print_name || b.filename); break; case 'date': // #2680: sort by real on-disk mtime (matches `ls -t`), falling back to // the DB created_at for managed uploads that have no filesystem mtime. comparison = (parseUTCDate(a.fs_modified_at ?? a.created_at)?.getTime() ?? 0) - (parseUTCDate(b.fs_modified_at ?? b.created_at)?.getTime() ?? 0); break; case 'size': comparison = a.file_size - b.file_size; break; case 'type': comparison = a.file_type.localeCompare(b.file_type); break; case 'prints': comparison = a.print_count - b.print_count; break; } return sortDirection === 'asc' ? comparison : -comparison; }); return result; }, [files, searchQuery, filterType, filterUsername, sortField, sortDirection]); // Check if disk space is low const isDiskSpaceLow = useMemo(() => { if (!stats || !settings) return false; const thresholdBytes = (settings.library_disk_warning_gb || 5) * 1024 * 1024 * 1024; return stats.disk_free_bytes < thresholdBytes; }, [stats, settings]); // Mutations const createFolderMutation = useMutation({ mutationFn: (data: LibraryFolderCreate) => api.createLibraryFolder(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['library-folders'] }); setShowNewFolderModal(false); showToast(t('fileManager.toast.folderCreated'), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const createExternalFolderMutation = useMutation({ mutationFn: async (data: ExternalFolderCreate) => { const folder = await api.createExternalFolder(data); // Auto-scan after creation await api.scanExternalFolder(folder.id); return folder; }, onSuccess: (folder) => { queryClient.invalidateQueries({ queryKey: ['library-folders'] }); queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-stats'] }); setShowExternalFolderModal(false); setSelectedFolderId(folder.id); showToast(t('fileManager.toast.externalFolderLinked'), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const scanExternalFolderMutation = useMutation({ mutationFn: (folderId: number) => api.scanExternalFolder(folderId), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-folders'] }); queryClient.invalidateQueries({ queryKey: ['library-stats'] }); showToast(t('fileManager.toast.folderScanned', { added: result.added, removed: result.removed }), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const deleteFolderMutation = useMutation({ mutationFn: (id: number) => api.deleteLibraryFolder(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['library-folders'] }); queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-stats'] }); if (selectedFolderId === deleteConfirm?.id) { setSelectedFolderId(null); } setDeleteConfirm(null); showToast(t('fileManager.toast.folderDeleted'), 'success'); }, onError: (error: Error) => { setDeleteConfirm(null); showToast(error.message, 'error'); }, }); const deleteFileMutation = useMutation({ mutationFn: (id: number) => api.deleteLibraryFile(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-folders'] }); queryClient.invalidateQueries({ queryKey: ['library-stats'] }); queryClient.invalidateQueries({ queryKey: ['library-trash-count'] }); setSelectedFiles((prev) => prev.filter((id) => id !== deleteConfirm?.id)); setDeleteConfirm(null); showToast(t('fileManager.toast.fileDeleted'), 'success'); }, onError: (error: Error) => { setDeleteConfirm(null); showToast(error.message, 'error'); }, }); // "These files are the same job for different printers" (#671 / #2570). // Durable, unlike the ad-hoc selection the Print button uses: once grouped, // printing any member offers the others without re-selecting them. const groupAsVersionsMutation = useMutation({ mutationFn: (fileIds: number[]) => api.createVariantGroup(fileIds.map((id) => ({ library_file_id: id }))), onSuccess: (group) => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); showToast(t('fileManager.variants.grouped', { count: group.members.length }), 'success'); setSelectedFiles([]); }, onError: (error: Error) => showToast(error.message, 'error'), }); const bulkDeleteMutation = useMutation({ mutationFn: (fileIds: number[]) => api.bulkDeleteLibrary(fileIds, []), onSuccess: (_, fileIds) => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-folders'] }); queryClient.invalidateQueries({ queryKey: ['library-stats'] }); queryClient.invalidateQueries({ queryKey: ['library-trash-count'] }); showToast(t('fileManager.toast.filesDeleted', { count: fileIds.length }), 'success'); setSelectedFiles([]); setDeleteConfirm(null); }, onError: (error: Error) => { setDeleteConfirm(null); showToast(error.message, 'error'); }, }); const moveFilesMutation = useMutation({ mutationFn: ({ fileIds, folderId }: { fileIds: number[]; folderId: number | null }) => api.moveLibraryFiles(fileIds, folderId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-folders'] }); setSelectedFiles([]); setShowMoveModal(false); showToast(t('fileManager.toast.filesMoved'), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const updateFolderMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: LibraryFolderUpdate }) => api.updateLibraryFolder(id, data), onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ['library-folders'] }); // Invalidate project/archive folder queries so other pages see the update queryClient.invalidateQueries({ queryKey: ['project-folders'] }); queryClient.invalidateQueries({ queryKey: ['archive-folders'] }); setLinkFolder(null); const isUnlink = variables.data.project_id === 0 && variables.data.archive_id === 0; showToast(isUnlink ? t('fileManager.toast.folderUnlinked') : t('fileManager.toast.folderLinked'), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const renameFileMutation = useMutation({ mutationFn: ({ id, filename }: { id: number; filename: string }) => api.updateLibraryFile(id, { filename }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); setRenameItem(null); showToast(t('fileManager.toast.fileRenamed'), 'success'); }, onError: (error: Error) => { setRenameItem(null); showToast(error.message, 'error'); }, }); const renameFolderMutation = useMutation({ mutationFn: ({ id, name }: { id: number; name: string }) => api.updateLibraryFolder(id, { name }), onSuccess: () => { // Invalidate both folders and files - files may display folder info queryClient.invalidateQueries({ queryKey: ['library-folders'] }); queryClient.invalidateQueries({ queryKey: ['library-files'] }); setRenameItem(null); showToast(t('fileManager.toast.folderRenamed'), 'success'); }, onError: (error: Error) => { setRenameItem(null); showToast(error.message, 'error'); }, }); const batchThumbnailMutation = useMutation({ mutationFn: () => api.batchGenerateStlThumbnails({ all_missing: true }), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); // Update thumbnail versions for cache busting if (result.succeeded > 0) { const now = Date.now(); const newVersions: Record = {}; result.results.forEach((r) => { if (r.success) { newVersions[r.file_id] = now; } }); setThumbnailVersions((prev) => ({ ...prev, ...newVersions })); } if (result.succeeded > 0 && result.failed === 0) { showToast(t('fileManager.toast.thumbnailsGenerated', { count: result.succeeded }), 'success'); } else if (result.succeeded > 0 && result.failed > 0) { showToast(t('fileManager.toast.thumbnailsGeneratedPartial', { succeeded: result.succeeded, failed: result.failed }), 'success'); } else if (result.processed === 0) { showToast(t('fileManager.toast.noStlMissingThumbnails'), 'info'); } else { showToast(t('fileManager.toast.failedToGenerateThumbnails', { error: result.results[0]?.error || 'Unknown error' }), 'error'); } }, onError: (error: Error) => showToast(error.message, 'error'), }); const singleThumbnailMutation = useMutation({ mutationFn: (fileId: number) => api.batchGenerateStlThumbnails({ file_ids: [fileId] }), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); // Update thumbnail version for cache busting if (result.succeeded > 0) { const fileId = result.results[0]?.file_id; if (fileId) { setThumbnailVersions((prev) => ({ ...prev, [fileId]: Date.now() })); } showToast(t('fileManager.toast.thumbnailGenerated'), 'success'); } else { showToast(t('fileManager.toast.failedToGenerateThumbnail', { error: result.results[0]?.error || 'Unknown error' }), 'error'); } }, onError: (error: Error) => showToast(error.message, 'error'), }); // Helper to check if a file is sliced (printable) const isSlicedFile = useCallback((filename: string) => { const lower = filename.toLowerCase(); return lower.endsWith('.gcode') || lower.includes('.gcode.'); }, []); // Get sliced files from selection const selectedSlicedFiles = useMemo(() => { if (!files) return []; return files.filter(f => selectedFiles.includes(f.id) && isSlicedFile(f.filename)); }, [files, selectedFiles, isSlicedFile]); // The clicked file's variant group, so printing one member offers the rest // without the user re-selecting them (#2570). const { data: printFileGroup } = useQuery({ queryKey: ['variant-group', printFile?.variant_group_id], queryFn: () => api.getVariantGroup(printFile!.variant_group_id!), enabled: !!printFile?.variant_group_id, }); // Candidates for a cross-model print (#671), or undefined for an ordinary one. // An explicit multi-selection wins over the group: the user just said, in this // action, which files they meant. const printVariantFiles = useMemo(() => { if (!printFile) return undefined; if (selectedSlicedFiles.length > 1) { return selectedSlicedFiles.map(f => ({ id: f.id, filename: f.filename, sliced_for_model: f.sliced_for_model, })); } if (printFileGroup && printFileGroup.members.length > 1) { return printFileGroup.members.map(m => ({ id: m.library_file_id, filename: m.filename, sliced_for_model: m.target_model, })); } return undefined; }, [printFile, selectedSlicedFiles, printFileGroup]); // Handlers const handleFileSelect = useCallback((id: number) => { // Always toggle selection (multi-select by default) setSelectedFiles((prev) => { return prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]; }); }, []); const handleSelectAll = useCallback(() => { if (filteredAndSortedFiles.length > 0) { setSelectedFiles(filteredAndSortedFiles.map((f) => f.id)); } }, [filteredAndSortedFiles]); const handleDeselectAll = useCallback(() => { setSelectedFiles([]); }, []); const handleUploadComplete = () => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-folders'] }); queryClient.invalidateQueries({ queryKey: ['library-stats'] }); }; // Page-wide drag-and-drop upload (#1510). Disabled when the user lacks // library:upload so a non-uploader can't accidentally show the overlay, // and also disabled while the upload modal itself is open so drags into // the modal's own drop zone don't bubble up and flash the page overlay // behind it. const canUpload = hasPermission('library:upload'); const { isDraggingOver, dragHandlers } = usePageFileDrop({ disabled: !canUpload || showUploadModal, onFiles: (files) => { setDroppedFiles(files); setShowUploadModal(true); }, }); const handleDownload = (id: number) => { api.downloadLibraryFile(id).catch((err) => { console.error('Library file download failed:', err); }); }; const handleDeleteConfirm = () => { if (!deleteConfirm) return; if (deleteConfirm.type === 'file') { deleteFileMutation.mutate(deleteConfirm.id); } else if (deleteConfirm.type === 'folder') { deleteFolderMutation.mutate(deleteConfirm.id); } else if (deleteConfirm.type === 'bulk') { bulkDeleteMutation.mutate(selectedFiles); } }; const isDeleting = deleteFolderMutation.isPending || deleteFileMutation.isPending || bulkDeleteMutation.isPending; const handleViewModeChange = (mode: 'grid' | 'list') => { setViewMode(mode); localStorage.setItem('library-view-mode', mode); }; const isLoading = foldersLoading || filesLoading; // Find the selected folder in the tree to check external status const selectedFolder = useMemo(() => { if (!selectedFolderId || !folders) return null; const findFolder = (items: LibraryFolderTree[]): LibraryFolderTree | null => { for (const item of items) { if (item.id === selectedFolderId) return item; const found = findFolder(item.children); if (found) return found; } return null; }; return findFolder(folders); }, [selectedFolderId, folders]); return (
{/* Drag & Drop Overlay — page-wide file upload (#1510) */} {isDraggingOver && (

{t('fileManager.dropFilesHere')}

{t('fileManager.releaseToUpload')}

)} {/* Header */}

{t('fileManager.title')}

{t('fileManager.subtitle')}

{/* View mode toggle */}
{hasPermission('library:purge') && ( )} {(hasAnyPermission('library:delete_own', 'library:delete_all')) && ( {t('libraryTrash.headerButton')} {typeof trashCount === 'number' && trashCount > 0 && ( {trashCount} )} )}
{/* Disk space warning */} {isDiskSpaceLow && stats && settings && (

{t('fileManager.lowDiskSpaceWarning')}

{t('fileManager.lowDiskSpaceDetails', { free: formatFileSize(stats.disk_free_bytes), total: formatFileSize(stats.disk_total_bytes), threshold: settings.library_disk_warning_gb })}

)} {/* Stats bar */} {stats && (
{t('fileManager.files')}: {stats.total_files}
{t('fileManager.folders')}: {stats.total_folders}
{t('fileManager.size')}: {formatFileSize(stats.total_size_bytes)}
{t('fileManager.free')}: {formatFileSize(stats.disk_free_bytes)}
)} {/* Main content */}
{/* Mobile folder selector */}
{/* Folder sidebar - resizable, hidden on mobile */}
{/* Resize handle - drag to resize, double-click to reset */}
{ e.preventDefault(); setIsResizing(true); }} onDoubleClick={() => { setSidebarWidth(256); // Reset to default w-64 localStorage.setItem('library-sidebar-width', '256'); }} title={t('fileManager.dragToResizeTooltip')} > {/* Grip dots */}

{t('fileManager.folders')}

{/* Folder tree sort (#1770). Dropdown drives the comparator; direction button flips asc/desc. Both persist to localStorage on change so the choice survives reloads. */}
{/* All Files = the user's own uploaded / managed-storage files only. External folders are surfaced separately below to keep a linked NAS from drowning the user's own uploads (#1621). */}
{ setSelectedFolderId(null); setTopLevelView('internal'); }} > {t('fileManager.allFiles')}
{/* External (combined) — only shown when at least one external folder is linked. Single folder users don't need a combined view; clicking the individual folder is just as fast. */} {folders?.some((f) => f.is_external) && (
{ setSelectedFolderId(null); setTopLevelView('external'); }} > {t('fileManager.allExternal')}
)} {/* Folder tree — re-key on the collapse toggle so flipping it remounts every FolderTreeItem, which re-reads defaultExpanded and makes the preference take effect immediately. */} {sortedFolders?.map((folder) => ( setDeleteConfirm({ type: 'folder', id })} onLink={setLinkFolder} onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })} wrapNames={wrapFolderNames} defaultExpanded={!collapseFoldersByDefault} showModified={showModified} hasPermission={hasPermission} t={t} /> ))}
{/* Files area + README rail (#2520 item 2). On wide screens the README docks as a collapsible right-hand column (rendered after the files column, below) so it no longer steals vertical space from the file list; on narrow screens it stacks above the list via `order-first` and the page itself scrolls. */}
{/* Tag filter rail (#1268). Lists every catalog tag as a togglable chip — active chips are filled green and show an X, inactive chips are outlined and toggle ON when clicked. Clicking an active chip removes it from the filter. Hidden entirely when the catalog is empty so brand-new installs don't see a stray rail. */} {tagCatalog.length > 0 && (
{t('fileManager.tags.filterLabel')} {tagCatalog.map((tg) => { const active = selectedTagIds.includes(tg.id); return ( ); })} {selectedTagIds.length > 0 && ( )}
)} {/* External folder info bar */} {selectedFolder?.is_external && (
{t('fileManager.externalFolder')} {selectedFolder.external_readonly && ( {t('fileManager.readOnly')} )}

{selectedFolder.external_path}

)} {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */} {files && files.length > 0 && (
{/* Search */}
setSearchQuery(e.target.value)} className="w-full pl-9 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" /> {searchExpandsSubfolders && ( {t('fileManager.searchSubfoldersHint')} )}
{/* Type filter */}
{/* Username filter with autocomplete - only show when auth is enabled */} {authEnabled && (
setFilterUsername(e.target.value)} list="usernames-list" className={`w-32 sm:w-40 px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green ${filterUsername ? 'pr-7' : ''}`} style={filterUsername ? { WebkitAppearance: 'none', MozAppearance: 'textfield' } : undefined} /> {filterUsername && ( )} {users?.map((user) => (
)} {/* Sort */}
{/* Results count */} {(searchQuery || filterType !== 'all' || filterUsername) && ( {t('fileManager.resultsCount', { showing: filteredAndSortedFiles.length, total: files.length })} )}
)} {/* Selection toolbar - sticky on mobile below search bar */} {filteredAndSortedFiles.length > 0 && (
{/* Select all / Deselect all */} {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? ( ) : ( )} {selectedFiles.length > 0 && ( <> {t('fileManager.selected', { count: selectedFiles.length })}
{/* Print used to disappear the moment a second sliced file was selected. Selecting several is now how you say "same job, different printers" (#671) — one queue item, whichever machine frees up first. */} {selectedSlicedFiles.length >= 1 && ( )} {selectedSlicedFiles.length >= 2 && !selectedSlicedFiles.some(f => f.variant_group_id) && ( )}
)}
)} {/* File grid/list */} {isLoading ? (

{t('fileManager.loadingFiles')}

) : files?.length === 0 ? (

{selectedFolderId !== null ? t('fileManager.folderIsEmpty') : topLevelView === 'external' ? t('fileManager.externalIsEmpty') : t('fileManager.noFilesYet')}

{selectedFolderId !== null ? t('fileManager.folderEmptyDescription') : topLevelView === 'external' ? t('fileManager.externalEmptyDescription') : t('fileManager.noFilesDescription')}

) : filteredAndSortedFiles.length === 0 ? (

{t('fileManager.noMatchingFiles')}

{t('fileManager.noMatchingFilesDescription')}

) : viewMode === 'grid' ? (
{filteredAndSortedFiles.map((file) => ( setDeleteConfirm({ type: 'file', id })} onDownload={handleDownload} onPrint={setPrintFile} onSlice={setSliceFile} onRunPipeline={setRunPipelineFile} useSlicerApi={settings?.use_slicer_api ?? false} onPreview3d={(f) => { // Sliced files (.gcode / .gcode.3mf) open the same // full-page gcode viewer the archive card uses, so // the two paths feel consistent. STL / source 3MF // continue to use the in-app 3D model viewer modal. if (isSlicedFilename(f.filename)) { navigate(`/gcode-viewer?library_file=${f.id}`); } else { setViewerFile(f); } }} onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })} onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)} onTagClick={toggleTagFilter} thumbnailVersion={thumbnailVersions[file.id]} hasPermission={hasPermission} canModify={canModify} authEnabled={authEnabled} showModified={showModified} /> ))}
) : (
{/* The wrapper has overflow-x-auto so a narrow viewport scrolls horizontally instead of clipping the actions column off the right edge. The previous `overflow-hidden` was there for the rounded corners but also swallowed any content the actions column couldn't fit (#1325 follow-up reported in chat). */}
{/* List header - hidden on mobile, show simplified on small screens. Trailing actions column is fixed at 220px (sliced 3MF = 7 icons ~220px). It used to be `min-content`, but header + body are sibling grids that compute `min-content` independently — the header's empty trailing div resolved to 0px, leaving body columns shifted left of their headers. Fixed width keeps header and body in lockstep. */}
{t('common.name')}
{authEnabled &&
{t('fileManager.uploadedBy', { defaultValue: 'Uploaded By' })}
}
{t('common.type')}
{t('fileManager.size')}
{t('fileManager.prints')}
{t('fileManager.tags.title')}
{/* List rows */} {filteredAndSortedFiles.map((file) => (
handleFileSelect(file.id)} > {/* Checkbox */}
{selectedFiles.includes(file.id) &&
}
{/* Name with thumbnail */}
{file.thumbnail_path ? ( ) : (
)}
{/* Hover preview */} {file.thumbnail_path && (
{file.filename}
)}
{file.print_name || file.filename}
{/* #2680: last-modified date under the name, toggled from the toolbar. Real on-disk mtime when known, else created_at. */} {showModified && (
{formatDate(file.fs_modified_at ?? file.created_at)}
)}
{/* Uploaded By - only show when auth is enabled */} {authEnabled && (
{file.created_by_username ? ( <> {file.created_by_username} ) : ( '-' )}
)} {/* Type */}
{file.file_type.toUpperCase()}
{/* Size */}
{formatFileSize(file.file_size)}
{/* Prints */}
{file.print_count > 0 ? `${file.print_count}x` : '-'}
{/* Tags (#1268) — clickable chips push into the active filter; minmax(0,200px) on the column lets the cell shrink/wrap on narrow viewports without pushing the Actions cell off-screen. */}
e.stopPropagation()}> {!file.tags || file.tags.length === 0 ? ( - ) : (
{file.tags.map((tg) => ( ))}
)}
{/* Actions */}
e.stopPropagation()}> {isSlicedFilename(file.filename) && ( <> )} {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && ( )} {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && ( )} {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'gcode.3mf' || file.file_type === 'stl') && ( )} {file.file_type === 'stl' && ( )}
))}
)}
{/* README rail — collapsible right column on lg+, stacks on top on mobile. See the files-area wrapper comment above (#2520). */} {selectedFolderId !== null && }
{/* Modals */} {showNewFolderModal && ( setShowNewFolderModal(false)} onSave={(data) => createFolderMutation.mutate(data)} isLoading={createFolderMutation.isPending} t={t} /> )} {showExternalFolderModal && ( setShowExternalFolderModal(false)} onSave={(data) => createExternalFolderMutation.mutate(data)} isLoading={createExternalFolderMutation.isPending} t={t} /> )} {showMoveModal && folders && ( setShowMoveModal(false)} onMove={(folderId) => moveFilesMutation.mutate({ fileIds: selectedFiles, folderId })} isLoading={moveFilesMutation.isPending} t={t} /> )} {showUploadModal && ( { setShowUploadModal(false); setDroppedFiles([]); }} onUploadComplete={handleUploadComplete} initialFiles={droppedFiles.length > 0 ? droppedFiles : undefined} /> )} {showPurgeModal && ( setShowPurgeModal(false)} /> )} setShowTagsModal(false)} onPickTag={(tagId) => { if (!selectedTagIds.includes(tagId)) { setSelectedTagIds((prev) => [...prev, tagId]); } }} /> setShowBulkTagsModal(false)} /> {linkFolder && ( setLinkFolder(null)} onLink={(data) => updateFolderMutation.mutate({ id: linkFolder.id, data })} isLoading={updateFolderMutation.isPending} t={t} /> )} {deleteConfirm && ( setDeleteConfirm(null)} /> )} {/* Held back until the variant group has loaded. The modal reads its candidate list once, on mount, so opening before the group arrives would show a single-file print for a file that has alternatives. */} {printFile && (!printFile.variant_group_id || printFileGroup !== undefined) && ( 1 ? `${printVariantFiles[0].filename} ${t('common.plusNMore', { count: printVariantFiles.length - 1 })}` : printFile.print_name || printFile.filename } onClose={() => setPrintFile(null)} onSuccess={() => { setPrintFile(null); setSelectedFiles([]); queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['queue'] }); queryClient.invalidateQueries({ queryKey: ['archives'] }); }} /> )} {sliceFile && ( setSliceFile(null)} /> )} {runPipelineFile && ( setRunPipelineFile(null)} /> )} {viewerFile && ( setViewerFile(null)} onSliceWithBambuddy={ // Only offer in-app slicing on files the SliceModal can actually // handle (matches the file-row Cog visibility check at :2127). isSliceableFilename(viewerFile.filename) && hasPermission('library:upload') ? () => { const f = viewerFile; setViewerFile(null); setSliceFile(f); } : undefined } /> )} {renameItem && ( setRenameItem(null)} onSave={(newName) => { if (renameItem.type === 'file') { renameFileMutation.mutate({ id: renameItem.id, filename: newName }); } else { renameFolderMutation.mutate({ id: renameItem.id, name: newName }); } }} isLoading={renameFileMutation.isPending || renameFolderMutation.isPending} t={t} /> )}
); }