import { useState, useRef, useEffect, useCallback } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Download, Trash2, Clock, Package, Coins, Layers, Search, Filter, Image, Box, Printer, Upload, ExternalLink, CheckSquare, Square, X, Globe, Pencil, LayoutGrid, List, CalendarDays, ArrowUpDown, Star, Tag, StickyNote, FolderOpen, Calendar, AlertCircle, Copy, Film, ScanSearch, QrCode, Camera, FileText, FileCode, MoreVertical, FileSpreadsheet, GitCompare, GitBranch, Loader2, FolderKanban, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Settings, User, Play, ClipboardList, Zap, Cog, Archive as ArchiveIcon, History, } from 'lucide-react'; import { api } from '../api/client'; import { SliceModal } from '../components/SliceModal'; import { openInSlicer, type SlicerType } from '../utils/slicer'; import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date'; import { getCurrencySymbol } from '../utils/currency'; import { getBedTypeInfo } from '../utils/bedType'; import { useIsMobile } from '../hooks/useIsMobile'; import type { Archive, ProjectListItem } from '../api/client'; import { Card, CardContent } from '../components/Card'; import { Button } from '../components/Button'; import { PrintModal } from '../components/PrintModal'; import { UploadModal } from '../components/UploadModal'; import { PurgeArchivesModal } from '../components/PurgeArchivesModal'; import { ConfirmModal } from '../components/ConfirmModal'; import { EditArchiveModal } from '../components/EditArchiveModal'; import { PrintLogModal } from '../components/PrintLogModal'; import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu'; import { BatchTagModal } from '../components/BatchTagModal'; import { BatchProjectModal } from '../components/BatchProjectModal'; import { CalendarView } from '../components/CalendarView'; import { QRCodeModal } from '../components/QRCodeModal'; import { PhotoGalleryModal } from '../components/PhotoGalleryModal'; import { ProjectPageModal } from '../components/ProjectPageModal'; import { TimelapseViewer } from '../components/TimelapseViewer'; import { CompareArchivesModal } from '../components/CompareArchivesModal'; import { PendingUploadsPanel } from '../components/PendingUploadsPanel'; import { TagManagementModal } from '../components/TagManagementModal'; import { PlatePickerModal } from '../components/PlatePickerModal'; import type { PlateMetadata } from '../types/plates'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; import { formatFileSize } from '../utils/file'; type TFunction = (key: string, options?: Record) => string; /** * Check if an archive represents a sliced/printable file. * Uses filename (.gcode, .gcode.3mf) as primary check, then falls back to * metadata — a .3mf with total_layers or print_time is sliced (contains gcode), * while a raw source .3mf (CAD export) has neither. */ function isSlicedFile(archive: { filename?: string | null; total_layers?: number | null; print_time_seconds?: number | null }): boolean { const filename = archive.filename; if (filename) { const lower = filename.toLowerCase(); if (lower.endsWith('.gcode') || lower.includes('.gcode.')) return true; } // .3mf can be either sliced or source — check for gcode metadata if (archive.total_layers || archive.print_time_seconds) return true; return false; } // formatDate imported from '../utils/date' - handles UTC conversion /** * Open an archive file in the slicer. * Fetches a short-lived download token, then builds a token-authenticated URL * that bypasses auth middleware (slicer protocol handlers can't send auth headers). */ async function openInSlicerWithToken( archiveId: number, filename: string, resourceType: 'file' | 'source', slicer: SlicerType, ): Promise { try { if (resourceType === 'source') { const { token } = await api.createSourceSlicerToken(archiveId); const path = api.getSourceSlicerDownloadUrl(archiveId, token, filename); openInSlicer(`${window.location.origin}${path}`, slicer); } else { const { token } = await api.createArchiveSlicerToken(archiveId); const path = api.getArchiveSlicerDownloadUrl(archiveId, token, filename); openInSlicer(`${window.location.origin}${path}`, slicer); } } catch { // Fallback to direct URL (works when auth is disabled) const path = resourceType === 'source' ? api.getSource3mfForSlicer(archiveId, filename) : api.getArchiveForSlicer(archiveId, filename); openInSlicer(`${window.location.origin}${path}`, slicer); } } function ArchiveCard({ archive, printerName, isSelected, onSelect, selectionMode, projects, isHighlighted, timeFormat = 'system', preferredSlicer = 'bambu_studio', useSlicerApi = false, currency, t, onNavigateToArchive, }: { archive: Archive; printerName: string; isSelected: boolean; onSelect: (id: number) => void; selectionMode: boolean; projects: ProjectListItem[] | undefined; isHighlighted?: boolean; timeFormat?: TimeFormat; preferredSlicer?: SlicerType; useSlicerApi?: boolean; currency: string; t: TFunction; onNavigateToArchive?: (archiveId: number) => void; }) { // Debug: log when card is highlighted if (isHighlighted) { console.log('ArchiveCard isHighlighted=true for archive:', archive.id); } const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission, canModify } = useAuth(); const isMobile = useIsMobile(); const navigate = useNavigate(); const [showReprint, setShowReprint] = useState(false); const [showSliceModal, setShowSliceModal] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); // #1343: when true, the delete also drops the row from Quick Stats. Default // off — soft delete preserves the archive's filament/time/cost contribution. const [deletePurgeStats, setDeletePurgeStats] = useState(false); const [showEdit, setShowEdit] = useState(false); const [showPrintLog, setShowPrintLog] = useState(false); const [showTimelapse, setShowTimelapse] = useState(false); const [showTimelapseSelect, setShowTimelapseSelect] = useState(false); const [availableTimelapses, setAvailableTimelapses] = useState>([]); const [showQRCode, setShowQRCode] = useState(false); const [showPhotos, setShowPhotos] = useState(false); const [showProjectPage, setShowProjectPage] = useState(false); const [showSchedule, setShowSchedule] = useState(false); const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false); const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false); const [showDeleteTimelapseConfirm, setShowDeleteTimelapseConfirm] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [currentPlateIndex, setCurrentPlateIndex] = useState(null); const [showPlateNav, setShowPlateNav] = useState(false); const [platePickerPlates, setPlatePickerPlates] = useState(null); const source3mfInputRef = useRef(null); const f3dInputRef = useRef(null); const timelapseInputRef = useRef(null); // Fetch plates data for multi-plate browsing (lazy - only when hovering) const { data: platesData } = useQuery({ queryKey: ['archive-plates', archive.id], queryFn: () => api.getArchivePlates(archive.id), enabled: showPlateNav, // Only fetch when user hovers to see navigation staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); // Use pre-computed duplicate sequence and original archive ID from list response const duplicateSequence = archive.duplicate_sequence ?? 0; const originalArchiveId = archive.original_archive_id ?? null; const plates = platesData?.plates ?? []; const isMultiPlate = platesData?.is_multi_plate ?? false; const displayPlateIndex = currentPlateIndex ?? 0; // 3D Preview click handler. Multi-plate archives show the plate picker // first; single-plate archives navigate straight into the viewer. Source- // only archives (no sliced gcode, e.g. pure project 3MFs from BambuStudio // that carry only plate PNG/JSON metadata) get a toast — there's nothing // the gcode viewer can render for them. const openGcodeViewer = async () => { try { const resp = await api.getArchivePlates(archive.id); if (resp.has_gcode === false) { showToast(t('archives.platePicker.noGcode'), 'info'); return; } if (resp.is_multi_plate && resp.plates.length > 1) { setPlatePickerPlates(resp.plates); return; } } catch { // Swallow — fall through to the no-plate navigate below so the viewer // still opens on the first plate (the backend's default). } navigate(`/gcode-viewer?archive=${archive.id}`); }; const timelapseDeleteMutation = useMutation({ mutationFn: () => api.deleteArchiveTimelapse(archive.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.timelapseRemoved')); }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedRemoveTimelapse'), 'error'); }, }); const timelapseUploadMutation = useMutation({ mutationFn: (file: File) => api.uploadArchiveTimelapse(archive.id, file), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.timelapseUploaded', { filename: data.filename })); }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedUploadTimelapse'), 'error'); }, }); const source3mfUploadMutation = useMutation({ mutationFn: (file: File) => api.uploadSource3mf(archive.id, file), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.source3mfAttached', { filename: data.filename })); }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedUploadSource3mf'), 'error'); }, }); const source3mfDeleteMutation = useMutation({ mutationFn: () => api.deleteSource3mf(archive.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.source3mfRemoved')); }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedRemoveSource3mf'), 'error'); }, }); const f3dUploadMutation = useMutation({ mutationFn: (file: File) => api.uploadF3d(archive.id, file), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.f3dAttached', { filename: data.filename })); }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedUploadF3d'), 'error'); }, }); const f3dDeleteMutation = useMutation({ mutationFn: () => api.deleteF3d(archive.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.f3dRemoved')); }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedRemoveF3d'), 'error'); }, }); const timelapseScanMutation = useMutation({ mutationFn: () => api.scanArchiveTimelapse(archive.id), onSuccess: (data) => { if (data.status === 'attached') { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.timelapseAttached', { filename: data.filename })); } else if (data.status === 'exists') { showToast(t('archives.toast.timelapseAlreadyAttached')); } else if (data.status === 'not_found' && data.available_files && data.available_files.length > 0) { // Show selection dialog setAvailableTimelapses(data.available_files); setShowTimelapseSelect(true); } else { showToast(data.message || t('archives.toast.noMatchingTimelapse'), 'warning'); } }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedScanTimelapse'), 'error'); }, }); const timelapseSelectMutation = useMutation({ mutationFn: (filename: string) => api.selectArchiveTimelapse(archive.id, filename), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.timelapseAttached', { filename: data.filename })); setShowTimelapseSelect(false); setAvailableTimelapses([]); }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedAttachTimelapse'), 'error'); }, }); const deleteMutation = useMutation({ mutationFn: (purgeStats: boolean) => api.deleteArchive(archive.id, purgeStats), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.archiveDeleted')); }, onError: () => { showToast(t('archives.toast.failedDeleteArchive'), 'error'); }, }); const favoriteMutation = useMutation({ mutationFn: () => api.toggleFavorite(archive.id), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(data.is_favorite ? t('archives.toast.addedToFavorites') : t('archives.toast.removedFromFavorites')); }, }); // Query for linked folders const { data: linkedFolders } = useQuery({ queryKey: ['archive-folders', archive.id], queryFn: () => api.getLibraryFoldersByArchive(archive.id), }); const assignProjectMutation = useMutation({ mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); queryClient.invalidateQueries({ queryKey: ['projects'] }); showToast(t('archives.toast.projectUpdated')); }, onError: () => { showToast(t('archives.toast.failedUpdateProject'), 'error'); }, }); const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }; const isGcodeFile = isSlicedFile(archive); const contextMenuItems: ContextMenuItem[] = [ // For gcode files: show Print option // For source files: show Slice as the primary action ...(isGcodeFile ? [ { label: t('archives.menu.print'), icon: , onClick: () => setShowReprint(true), disabled: !archive.file_path || !canModify('archives', 'reprint', archive.created_by_id), title: !archive.file_path ? t('archives.card.noFileForReprint') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.permission.noReprint') : undefined, }, { label: t('archives.menu.schedule'), icon: , onClick: () => setShowSchedule(true), disabled: !archive.file_path || !hasPermission('queue:create'), title: !archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : undefined, }, { label: t('archives.menu.openInBambuStudio'), icon: , onClick: () => { const filename = archive.print_name || archive.filename || 'model'; openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer); }, disabled: !archive.file_path, title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined, }, ] : [ { label: t('archives.menu.slice'), icon: useSlicerApi ? : , onClick: () => { if (useSlicerApi) { setShowSliceModal(true); } else { const filename = archive.print_name || archive.filename || 'model'; openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer); } }, }, ]), { label: archive.external_url ? t('archives.menu.externalLink') : t('archives.menu.viewOnMakerWorld'), icon: , onClick: () => { const url = archive.external_url || archive.makerworld_url; if (url) window.open(url, '_blank'); }, disabled: !archive.external_url && !archive.makerworld_url, }, { label: '', divider: true, onClick: () => {} }, { label: t('archives.menu.preview3d'), icon: , onClick: () => { openGcodeViewer(); }, }, { label: t('archives.menu.viewTimelapse'), icon: , onClick: () => setShowTimelapse(true), disabled: !archive.timelapse_path, }, { label: t('archives.menu.scanForTimelapse'), icon: , onClick: () => timelapseScanMutation.mutate(), disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }, { label: t('archives.menu.uploadTimelapse'), icon: , onClick: () => timelapseInputRef.current?.click(), disabled: !!archive.timelapse_path || !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }, ...(archive.timelapse_path ? [{ label: t('archives.menu.removeTimelapse'), icon: , onClick: () => setShowDeleteTimelapseConfirm(true), danger: true, disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }] : []), { label: '', divider: true, onClick: () => {} }, { label: archive.source_3mf_path ? t('archives.menu.downloadSource3mf') : t('archives.menu.uploadSource3mf'), icon: , onClick: () => { if (archive.source_3mf_path) { api.downloadSource3mf(archive.id).catch((err) => { console.error('Source 3MF download failed:', err); }); } else { source3mfInputRef.current?.click(); } }, disabled: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id), title: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUploadFiles') : undefined, }, ...(archive.source_3mf_path ? [{ label: t('archives.menu.replaceSource3mf'), icon: , onClick: () => source3mfInputRef.current?.click(), disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }, { label: t('archives.menu.removeSource3mf'), icon: , onClick: () => setShowDeleteSource3mfConfirm(true), danger: true, disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }] : []), { label: archive.f3d_path ? t('archives.menu.replaceF3d') : t('archives.menu.uploadF3d'), icon: , onClick: () => f3dInputRef.current?.click(), disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }, ...(archive.f3d_path ? [{ label: t('archives.menu.downloadF3d'), icon: , onClick: () => { api.downloadF3d(archive.id).catch((err) => { console.error('F3D download failed:', err); }); }, }, { label: t('archives.menu.removeF3d'), icon: , onClick: () => setShowDeleteF3dConfirm(true), danger: true, disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }] : []), { label: '', divider: true, onClick: () => {} }, { label: t('archives.menu.download'), icon: , onClick: () => { api.downloadArchive(archive.id, `${archive.print_name || archive.filename}.3mf`).catch((err) => { console.error('Archive download failed:', err); }); }, disabled: !hasPermission('archives:read'), title: !hasPermission('archives:read') ? t('archives.permission.noDownload') : undefined, }, { label: t('archives.menu.copyDownloadLink'), icon: , onClick: () => { const url = `${window.location.origin}${api.getArchiveDownload(archive.id)}`; navigator.clipboard.writeText(url).then(() => { showToast(t('archives.toast.linkCopied')); }).catch(() => { showToast(t('archives.toast.failedCopyLink'), 'error'); }); }, disabled: !hasPermission('archives:read'), title: !hasPermission('archives:read') ? t('archives.permission.noCopyLink') : undefined, }, { label: t('archives.menu.qrCode'), icon: , onClick: () => setShowQRCode(true), }, { label: archive.photos?.length ? t('archives.menu.viewPhotosCount', { count: archive.photos.length }) : t('archives.menu.viewPhotos'), icon: , onClick: () => setShowPhotos(true), disabled: !archive.photos?.length, }, { label: t('archives.menu.projectPage'), icon: , onClick: () => setShowProjectPage(true), }, { label: '', divider: true, onClick: () => {} }, { label: archive.is_favorite ? t('archives.menu.removeFromFavorites') : t('archives.menu.addToFavorites'), icon: , onClick: () => favoriteMutation.mutate(), disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }, { label: t('archives.menu.edit'), icon: , onClick: () => setShowEdit(true), disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }, { label: t('archives.menu.printLog'), icon: , onClick: () => setShowPrintLog(true), }, ...(archive.project_id && archive.project_name ? [{ label: t('archives.menu.goToProject', { name: archive.project_name }), icon: , onClick: () => window.location.href = '/projects', }] : []), { label: t('archives.menu.addToProject'), icon: , onClick: () => {}, disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, submenuSearchPlaceholder: (projects?.filter(p => p.status === 'active').length ?? 0) > 5 ? t('archives.menu.searchProjects') : undefined, submenu: (() => { const items: ContextMenuItem[] = []; // Add "Remove from Project" if archive is in a project if (archive.project_id) { items.push({ label: t('archives.menu.removeFromProject'), icon: , onClick: () => assignProjectMutation.mutate(null), disabled: !canModify('archives', 'update', archive.created_by_id), }); } // Add project options if (!projects) { items.push({ label: t('archives.menu.loading'), icon: , onClick: () => {}, disabled: true, }); } else { const activeProjects = projects .filter(p => p.status === 'active') .sort((a, b) => a.name.localeCompare(b.name)); if (activeProjects.length === 0) { items.push({ label: t('archives.menu.noProjectsAvailable'), icon: , onClick: () => {}, disabled: true, }); } else { activeProjects.forEach(p => { items.push({ label: p.name, icon:
, onClick: () => assignProjectMutation.mutate(p.id), disabled: archive.project_id === p.id || !canModify('archives', 'update', archive.created_by_id), }); }); } } return items; })(), }, { label: isSelected ? t('archives.menu.deselect') : t('archives.menu.select'), icon: isSelected ? : , onClick: () => onSelect(archive.id), }, { label: '', divider: true, onClick: () => {} }, { label: t('archives.menu.delete'), icon: , onClick: () => setShowDeleteConfirm(true), danger: true, disabled: !canModify('archives', 'delete', archive.created_by_id), title: !canModify('archives', 'delete', archive.created_by_id) ? t('archives.permission.noDelete') : undefined, }, ]; return ( onSelect(archive.id) : undefined} > {/* Selection checkbox */} {selectionMode && ( )} {/* Thumbnail with plate navigation */}
setShowPlateNav(true)} onMouseLeave={() => setShowPlateNav(false)} > {archive.thumbnail_path ? ( 0 ? api.getArchivePlateThumbnail(archive.id, plates[displayPlateIndex]?.index ?? 0) : api.getArchiveThumbnail(archive.id) } alt={archive.print_name || archive.filename} className="w-full h-full object-cover" /> ) : (
)} {/* Plate navigation - only show for multi-plate archives */} {isMultiPlate && plates.length > 1 && ( <> {/* Left arrow */} {/* Right arrow */} {/* Dots indicator */}
{plates.map((plate, idx) => (
)} {/* Context menu button - visible on mobile, shows on hover for desktop */} {/* Favorite star */} {(archive.status === 'failed' || archive.status === 'aborted') && (
{archive.status === 'aborted' ? t('archives.card.cancelled') : t('archives.card.failed')}
)} {/* Duplicate badge */} {archive.duplicate_count > 0 && duplicateSequence > 0 && originalArchiveId && ( )} {archive.duplicate_count > 0 && duplicateSequence === 0 && ( +{archive.duplicate_count} )} {/* Source 3MF badge */} {archive.source_3mf_path && ( )} {/* F3D badge */} {archive.f3d_path && ( )} {/* 3D preview badge */} {/* Timelapse badge */} {archive.timelapse_path && ( )} {/* Photos badge */} {archive.photos && archive.photos.length > 0 && ( )} {/* Linked folder badge */} {linkedFolders && linkedFolders.length > 0 && ( e.stopPropagation()} title={t('archives.card.openFolder', { name: linkedFolders[0].name })} style={{ left: archive.source_3mf_path ? (archive.f3d_path ? '5.5rem' : '3rem') : (archive.f3d_path ? '3rem' : '0.5rem') }} > )}
{/* Archive ID */}

#{archive.id}

{/* Title */}

{archive.print_name || archive.filename}

{printerName}

{(() => { const bed = getBedTypeInfo(archive.bed_type); return bed ? ( {bed.label} ) : null; })()} {/* File type badge */} {isSlicedFile(archive) ? t('archives.card.gcode') : t('archives.card.source')} {/* File hash badge */} {archive.content_hash && ( {archive.content_hash.slice(0, 8).toUpperCase()} )} {archive.project_name && ( p.id === archive.project_id)?.color || '#6b7280'}20`, color: projects?.find(p => p.id === archive.project_id)?.color || '#6b7280' }} title={t('archives.card.project', { name: archive.project_name })} > {archive.project_name} )} {archive.run_count > 1 && ( )}
{/* Stats */}
{(archive.print_time_seconds || archive.actual_time_seconds) && (
{formatDuration(archive.actual_time_seconds || archive.print_time_seconds || 0)} {archive.time_accuracy && ( = 95 && archive.time_accuracy <= 105 ? 'bg-bambu-green/20 text-bambu-green' : archive.time_accuracy > 105 ? 'bg-blue-500/20 text-blue-400' : 'bg-orange-500/20 text-orange-400' }`}> {archive.time_accuracy > 100 ? '+' : ''}{(archive.time_accuracy - 100).toFixed(0)}% )}
)} {archive.filament_used_grams && (
{archive.filament_used_grams.toFixed(1)}g
)} {(archive.cost != null || archive.energy_cost != null) && (
{archive.cost != null && (
{currency}{archive.cost.toFixed(2)}
)} {archive.energy_cost != null && (
{currency}{archive.energy_cost.toFixed(2)}
)}
)} {(archive.layer_height || archive.total_layers) && (
{archive.total_layers && {archive.total_layers === 1 ? t('archives.card.layer', { count: archive.total_layers }) : t('archives.card.layers', { count: archive.total_layers })}} {archive.total_layers && archive.layer_height && ·} {archive.layer_height && {archive.layer_height}mm}
)} {archive.object_count != null && archive.object_count > 0 && (
{archive.object_count === 1 ? t('archives.card.object', { count: archive.object_count }) : t('archives.card.objects', { count: archive.object_count })}
)} {archive.sliced_for_model && (
{archive.sliced_for_model}
)} {archive.filament_type && (
{archive.filament_type} {archive.filament_color && (
{archive.filament_color.split(',').map((color, i) => (
))}
)}
)}
{/* Tags & Notes */} {(archive.tags || archive.notes) && (
{archive.notes && (
)} {archive.tags?.split(',').map((tag, i) => ( {tag.trim()} ))}
)} {/* Spacer to push content to bottom */}
{/* Date, Size & Creator */}
{formatDateTime(archive.created_at, timeFormat)}
{archive.created_by_username && ( {archive.created_by_username} )} {formatFileSize(archive.file_size)}
{/* Actions */}
{isSlicedFile(archive) ? ( // Sliced file - can print directly <> ) : ( // Source file only - "Slice" action )}
{/* Edit Modal */} {showEdit && ( setShowEdit(false)} /> )} {/* Print Log Modal — opened from the "N prints" badge or context menu (#1378) */} {showPrintLog && ( setShowPrintLog(false)} /> )} {/* Plate picker — shown only for multi-plate archives on 3D Preview click */} {platePickerPlates && ( { setPlatePickerPlates(null); navigate(`/gcode-viewer?archive=${archive.id}&plate=${plateIndex}`); }} onClose={() => setPlatePickerPlates(null)} /> )} {/* Reprint Modal */} {showReprint && ( setShowReprint(false)} /> )} {/* Slice Modal */} {showSliceModal && ( setShowSliceModal(false)} /> )} {/* Delete Confirmation */} {showDeleteConfirm && ( { deleteMutation.mutate(deletePurgeStats); setShowDeleteConfirm(false); setDeletePurgeStats(false); }} onCancel={() => { setShowDeleteConfirm(false); setDeletePurgeStats(false); }} > {/* #1343: opt-in checkbox — by default the archive is soft-deleted, so its filament / time / cost contribution stays in Quick Stats. */} )} {/* Delete Source 3MF Confirmation */} {showDeleteSource3mfConfirm && ( { source3mfDeleteMutation.mutate(); setShowDeleteSource3mfConfirm(false); }} onCancel={() => setShowDeleteSource3mfConfirm(false)} /> )} {/* Delete F3D Confirmation */} {showDeleteF3dConfirm && ( { f3dDeleteMutation.mutate(); setShowDeleteF3dConfirm(false); }} onCancel={() => setShowDeleteF3dConfirm(false)} /> )} {/* Delete Timelapse Confirmation */} {showDeleteTimelapseConfirm && ( { timelapseDeleteMutation.mutate(); setShowDeleteTimelapseConfirm(false); }} onCancel={() => setShowDeleteTimelapseConfirm(false)} /> )} {/* Context Menu */} {contextMenu && ( setContextMenu(null)} /> )} {/* Timelapse Viewer Modal */} {showTimelapse && archive.timelapse_path && ( setShowTimelapse(false)} onEdit={() => { queryClient.invalidateQueries({ queryKey: ['archives'] }); setShowTimelapse(false); // Close viewer to reload fresh video }} /> )} {/* Timelapse Selection Modal */} {showTimelapseSelect && availableTimelapses.length > 0 && (

{t('archives.modal.selectTimelapse')}

{t('archives.modal.selectTimelapseDesc')}

{availableTimelapses.map((file) => ( ))}
)} {/* QR Code Modal */} {showQRCode && ( setShowQRCode(false)} /> )} {/* Photo Gallery Modal */} {showPhotos && archive.photos && archive.photos.length > 0 && ( setShowPhotos(false)} onDelete={async (filename) => { try { await api.deleteArchivePhoto(archive.id, filename); queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.photoDeleted')); } catch { showToast(t('archives.toast.failedDeletePhoto'), 'error'); } }} /> )} {/* Project Page Modal */} {showProjectPage && ( setShowProjectPage(false)} /> )} {showSchedule && ( setShowSchedule(false)} /> )} {/* Hidden file input for source 3MF upload */} { const file = e.target.files?.[0]; if (file) { source3mfUploadMutation.mutate(file); } e.target.value = ''; }} /> {/* Hidden file input for F3D upload */} { const file = e.target.files?.[0]; if (file) { f3dUploadMutation.mutate(file); } e.target.value = ''; }} /> {/* Hidden file input for timelapse upload */} { const file = e.target.files?.[0]; if (file) { timelapseUploadMutation.mutate(file); } e.target.value = ''; }} /> ); } function ArchiveListRow({ archive, printerName, isSelected, onSelect, selectionMode, projects, isHighlighted, preferredSlicer = 'bambu_studio', useSlicerApi = false, t, onNavigateToArchive, }: { archive: Archive; printerName: string; isSelected: boolean; onSelect: (id: number) => void; selectionMode: boolean; projects: ProjectListItem[] | undefined; isHighlighted?: boolean; preferredSlicer?: SlicerType; useSlicerApi?: boolean; t: TFunction; onNavigateToArchive?: (archiveId: number) => void; }) { const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission, canModify } = useAuth(); const [showEdit, setShowEdit] = useState(false); const [showPrintLog, setShowPrintLog] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); // #1343: opt-in "Also remove from statistics" checkbox state. Default off // — soft delete keeps the archive's contribution to Quick Stats. const [deletePurgeStats, setDeletePurgeStats] = useState(false); const navigate = useNavigate(); const [showReprint, setShowReprint] = useState(false); const [showSliceModal, setShowSliceModal] = useState(false); const [showSchedule, setShowSchedule] = useState(false); const [showTimelapse, setShowTimelapse] = useState(false); const [showTimelapseSelect, setShowTimelapseSelect] = useState(false); const [availableTimelapses, setAvailableTimelapses] = useState>([]); const [showQRCode, setShowQRCode] = useState(false); const [showPhotos, setShowPhotos] = useState(false); const [showProjectPage, setShowProjectPage] = useState(false); const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false); const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false); const [showDeleteTimelapseConfirm, setShowDeleteTimelapseConfirm] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const source3mfInputRef = useRef(null); const f3dInputRef = useRef(null); const timelapseInputRef = useRef(null); const [platePickerPlates, setPlatePickerPlates] = useState(null); // Use pre-computed duplicate sequence and original archive ID from list response const duplicateSequence = archive.duplicate_sequence ?? 0; const originalArchiveId = archive.original_archive_id ?? null; // 3D Preview click handler. Multi-plate archives show the plate picker // first; single-plate archives navigate straight into the viewer. const openGcodeViewer = async () => { try { const resp = await api.getArchivePlates(archive.id); if (resp.is_multi_plate && resp.plates.length > 1) { setPlatePickerPlates(resp.plates); return; } } catch { // Swallow — fall through to navigate on the first-plate default. } navigate(`/gcode-viewer?archive=${archive.id}`); }; const timelapseDeleteMutation = useMutation({ mutationFn: () => api.deleteArchiveTimelapse(archive.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.timelapseRemoved')); }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedRemoveTimelapse'), 'error'); }, }); const timelapseUploadMutation = useMutation({ mutationFn: (file: File) => api.uploadArchiveTimelapse(archive.id, file), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.timelapseUploaded', { filename: data.filename })); }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedUploadTimelapse'), 'error'); }, }); const source3mfUploadMutation = useMutation({ mutationFn: (file: File) => api.uploadSource3mf(archive.id, file), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.source3mfAttached', { filename: data.filename })); }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedUploadSource3mf'), 'error'); }, }); const source3mfDeleteMutation = useMutation({ mutationFn: () => api.deleteSource3mf(archive.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.source3mfRemoved')); }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedRemoveSource3mf'), 'error'); }, }); const f3dUploadMutation = useMutation({ mutationFn: (file: File) => api.uploadF3d(archive.id, file), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.f3dAttached', { filename: data.filename })); }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedUploadF3d'), 'error'); }, }); const f3dDeleteMutation = useMutation({ mutationFn: () => api.deleteF3d(archive.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.f3dRemoved')); }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedRemoveF3d'), 'error'); }, }); const timelapseScanMutation = useMutation({ mutationFn: () => api.scanArchiveTimelapse(archive.id), onSuccess: (data) => { if (data.status === 'attached') { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.timelapseAttached', { filename: data.filename })); } else if (data.status === 'exists') { showToast(t('archives.toast.timelapseAlreadyAttached')); } else if (data.status === 'not_found' && data.available_files && data.available_files.length > 0) { setAvailableTimelapses(data.available_files); setShowTimelapseSelect(true); } else { showToast(data.message || t('archives.toast.noMatchingTimelapse'), 'warning'); } }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedScanTimelapse'), 'error'); }, }); const timelapseSelectMutation = useMutation({ mutationFn: (filename: string) => api.selectArchiveTimelapse(archive.id, filename), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.timelapseAttached', { filename: data.filename })); setShowTimelapseSelect(false); setAvailableTimelapses([]); }, onError: (error: Error) => { showToast(error.message || t('archives.toast.failedAttachTimelapse'), 'error'); }, }); const deleteMutation = useMutation({ mutationFn: (purgeStats: boolean) => api.deleteArchive(archive.id, purgeStats), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.archiveDeleted')); }, onError: () => { showToast(t('archives.toast.failedDeleteArchive'), 'error'); }, }); const favoriteMutation = useMutation({ mutationFn: () => api.toggleFavorite(archive.id), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(data.is_favorite ? t('archives.toast.addedToFavorites') : t('archives.toast.removedFromFavorites')); }, }); // Query for linked folders const { data: linkedFolders } = useQuery({ queryKey: ['archive-folders', archive.id], queryFn: () => api.getLibraryFoldersByArchive(archive.id), }); const assignProjectMutation = useMutation({ mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); queryClient.invalidateQueries({ queryKey: ['projects'] }); showToast(t('archives.toast.projectUpdated')); }, onError: () => { showToast(t('archives.toast.failedUpdateProject'), 'error'); }, }); const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }; const isGcodeFile = isSlicedFile(archive); const contextMenuItems: ContextMenuItem[] = [ ...(isGcodeFile ? [ { label: t('archives.menu.print'), icon: , onClick: () => setShowReprint(true), disabled: !archive.file_path || !canModify('archives', 'reprint', archive.created_by_id), title: !archive.file_path ? t('archives.card.noFileForReprint') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.permission.noReprint') : undefined, }, { label: t('archives.menu.schedule'), icon: , onClick: () => setShowSchedule(true), disabled: !archive.file_path || !hasPermission('queue:create'), title: !archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : undefined, }, { label: t('archives.menu.openInBambuStudio'), icon: , onClick: () => { const filename = archive.print_name || archive.filename || 'model'; openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer); }, disabled: !archive.file_path, title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined, }, ] : [ { label: t('archives.menu.slice'), icon: useSlicerApi ? : , onClick: () => { if (useSlicerApi) { setShowSliceModal(true); } else { const filename = archive.print_name || archive.filename || 'model'; openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer); } }, }, ]), { label: archive.external_url ? t('archives.menu.externalLink') : t('archives.menu.viewOnMakerWorld'), icon: , onClick: () => { const url = archive.external_url || archive.makerworld_url; if (url) window.open(url, '_blank'); }, disabled: !archive.external_url && !archive.makerworld_url, }, { label: '', divider: true, onClick: () => {} }, { label: t('archives.menu.preview3d'), icon: , onClick: () => { openGcodeViewer(); }, }, { label: t('archives.menu.viewTimelapse'), icon: , onClick: () => setShowTimelapse(true), disabled: !archive.timelapse_path, }, { label: t('archives.menu.scanForTimelapse'), icon: , onClick: () => timelapseScanMutation.mutate(), disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }, { label: t('archives.menu.uploadTimelapse'), icon: , onClick: () => timelapseInputRef.current?.click(), disabled: !!archive.timelapse_path || !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }, ...(archive.timelapse_path ? [{ label: t('archives.menu.removeTimelapse'), icon: , onClick: () => setShowDeleteTimelapseConfirm(true), danger: true, disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }] : []), { label: '', divider: true, onClick: () => {} }, { label: archive.source_3mf_path ? t('archives.menu.downloadSource3mf') : t('archives.menu.uploadSource3mf'), icon: , onClick: () => { if (archive.source_3mf_path) { api.downloadSource3mf(archive.id).catch((err) => { console.error('Source 3MF download failed:', err); }); } else { source3mfInputRef.current?.click(); } }, disabled: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id), title: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUploadFiles') : undefined, }, ...(archive.source_3mf_path ? [{ label: t('archives.menu.replaceSource3mf'), icon: , onClick: () => source3mfInputRef.current?.click(), disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }, { label: t('archives.menu.removeSource3mf'), icon: , onClick: () => setShowDeleteSource3mfConfirm(true), danger: true, disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }] : []), { label: archive.f3d_path ? t('archives.menu.replaceF3d') : t('archives.menu.uploadF3d'), icon: , onClick: () => f3dInputRef.current?.click(), disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }, ...(archive.f3d_path ? [{ label: t('archives.menu.downloadF3d'), icon: , onClick: () => { api.downloadF3d(archive.id).catch((err) => { console.error('F3D download failed:', err); }); }, }, { label: t('archives.menu.removeF3d'), icon: , onClick: () => setShowDeleteF3dConfirm(true), danger: true, disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }] : []), { label: '', divider: true, onClick: () => {} }, { label: t('archives.menu.download'), icon: , onClick: () => { api.downloadArchive(archive.id, `${archive.print_name || archive.filename}.3mf`).catch((err) => { console.error('Archive download failed:', err); }); }, disabled: !hasPermission('archives:read'), title: !hasPermission('archives:read') ? t('archives.permission.noDownload') : undefined, }, { label: t('archives.menu.copyDownloadLink'), icon: , onClick: () => { const url = `${window.location.origin}${api.getArchiveDownload(archive.id)}`; navigator.clipboard.writeText(url).then(() => { showToast(t('archives.toast.linkCopied')); }).catch(() => { showToast(t('archives.toast.failedCopyLink'), 'error'); }); }, disabled: !hasPermission('archives:read'), title: !hasPermission('archives:read') ? t('archives.permission.noCopyLink') : undefined, }, { label: t('archives.menu.qrCode'), icon: , onClick: () => setShowQRCode(true), }, { label: archive.photos?.length ? t('archives.menu.viewPhotosCount', { count: archive.photos.length }) : t('archives.menu.viewPhotos'), icon: , onClick: () => setShowPhotos(true), disabled: !archive.photos?.length, }, { label: t('archives.menu.projectPage'), icon: , onClick: () => setShowProjectPage(true), }, { label: '', divider: true, onClick: () => {} }, { label: archive.is_favorite ? t('archives.menu.removeFromFavorites') : t('archives.menu.addToFavorites'), icon: , onClick: () => favoriteMutation.mutate(), disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }, { label: t('archives.menu.edit'), icon: , onClick: () => setShowEdit(true), disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, }, { label: t('archives.menu.printLog'), icon: , onClick: () => setShowPrintLog(true), }, ...(archive.project_id && archive.project_name ? [{ label: t('archives.menu.goToProject', { name: archive.project_name }), icon: , onClick: () => window.location.href = '/projects', }] : []), { label: t('archives.menu.addToProject'), icon: , onClick: () => {}, submenuSearchPlaceholder: (projects?.filter(p => p.status === 'active').length ?? 0) > 5 ? t('archives.menu.searchProjects') : undefined, submenu: (() => { const items: ContextMenuItem[] = []; if (archive.project_id) { items.push({ label: t('archives.menu.removeFromProject'), icon: , onClick: () => assignProjectMutation.mutate(null), }); } if (!projects) { items.push({ label: t('archives.menu.loading'), icon: , onClick: () => {}, disabled: true, }); } else { const activeProjects = projects .filter(p => p.status === 'active') .sort((a, b) => a.name.localeCompare(b.name)); if (activeProjects.length === 0) { items.push({ label: t('archives.menu.noProjectsAvailable'), icon: , onClick: () => {}, disabled: true, }); } else { activeProjects.forEach(p => { items.push({ label: p.name, icon:
, onClick: () => assignProjectMutation.mutate(p.id), disabled: archive.project_id === p.id, }); }); } } return items; })(), }, { label: isSelected ? t('archives.menu.deselect') : t('archives.menu.select'), icon: isSelected ? : , onClick: () => onSelect(archive.id), }, { label: '', divider: true, onClick: () => {} }, { label: t('archives.menu.delete'), icon: , onClick: () => setShowDeleteConfirm(true), danger: true, disabled: !canModify('archives', 'delete', archive.created_by_id), title: !canModify('archives', 'delete', archive.created_by_id) ? t('archives.permission.noDelete') : undefined, }, ]; return ( <>
{selectionMode && ( )} {archive.thumbnail_path ? ( ) : (
)}

{archive.print_name || archive.filename}

{(archive.status === 'failed' || archive.status === 'aborted') && ( {archive.status === 'aborted' ? t('archives.card.cancelled') : t('archives.card.failed')} )} {archive.duplicate_count > 0 && duplicateSequence > 0 && originalArchiveId && ( )} {archive.duplicate_count > 0 && duplicateSequence === 0 && ( +{archive.duplicate_count} )} {archive.timelapse_path && ( )} {linkedFolders && linkedFolders.length > 0 && ( e.stopPropagation()} > )}
{(archive.filament_type || archive.sliced_for_model) && (
{archive.sliced_for_model && ( {archive.sliced_for_model} )} {(() => { const bed = getBedTypeInfo(archive.bed_type); return bed ? ( {bed.label} ) : null; })()} {archive.sliced_for_model && archive.filament_type && ( · )} {archive.filament_type && ( {archive.filament_type} )} {archive.filament_color && (
{archive.filament_color.split(',').map((color, i) => (
))}
)}
)}
{printerName}
{formatDateOnly(archive.created_at)}
{archive.created_by_username && (
{archive.created_by_username}
)}
{formatFileSize(archive.file_size)}
{isSlicedFile(archive) && ( )} {(archive.external_url || archive.makerworld_url) && ( )}
{/* Edit Modal */} {showEdit && ( setShowEdit(false)} /> )} {/* Print Log Modal — opened from the "N prints" badge or context menu (#1378) */} {showPrintLog && ( setShowPrintLog(false)} /> )} {/* Plate picker — shown only for multi-plate archives on 3D Preview click */} {platePickerPlates && ( { setPlatePickerPlates(null); navigate(`/gcode-viewer?archive=${archive.id}&plate=${plateIndex}`); }} onClose={() => setPlatePickerPlates(null)} /> )} {/* Reprint Modal */} {showReprint && ( setShowReprint(false)} /> )} {/* Slice Modal */} {showSliceModal && ( setShowSliceModal(false)} /> )} {/* Delete Confirmation */} {showDeleteConfirm && ( { deleteMutation.mutate(deletePurgeStats); setShowDeleteConfirm(false); setDeletePurgeStats(false); }} onCancel={() => { setShowDeleteConfirm(false); setDeletePurgeStats(false); }} > {/* #1343: opt-in checkbox — by default the archive is soft-deleted, so its filament / time / cost contribution stays in Quick Stats. */} )} {/* Delete Source 3MF Confirmation */} {showDeleteSource3mfConfirm && ( { source3mfDeleteMutation.mutate(); setShowDeleteSource3mfConfirm(false); }} onCancel={() => setShowDeleteSource3mfConfirm(false)} /> )} {/* Delete F3D Confirmation */} {showDeleteF3dConfirm && ( { f3dDeleteMutation.mutate(); setShowDeleteF3dConfirm(false); }} onCancel={() => setShowDeleteF3dConfirm(false)} /> )} {/* Delete Timelapse Confirmation */} {showDeleteTimelapseConfirm && ( { timelapseDeleteMutation.mutate(); setShowDeleteTimelapseConfirm(false); }} onCancel={() => setShowDeleteTimelapseConfirm(false)} /> )} {/* Context Menu */} {contextMenu && ( setContextMenu(null)} /> )} {/* Timelapse Viewer Modal */} {showTimelapse && archive.timelapse_path && ( setShowTimelapse(false)} onEdit={() => { queryClient.invalidateQueries({ queryKey: ['archives'] }); setShowTimelapse(false); }} /> )} {/* Timelapse Selection Modal */} {showTimelapseSelect && availableTimelapses.length > 0 && (

{t('archives.modal.selectTimelapse')}

{t('archives.modal.selectTimelapseDesc')}

{availableTimelapses.map((file) => ( ))}
)} {/* QR Code Modal */} {showQRCode && ( setShowQRCode(false)} /> )} {/* Photo Gallery Modal */} {showPhotos && archive.photos && ( setShowPhotos(false)} onDelete={async (filename) => { try { await api.deleteArchivePhoto(archive.id, filename); queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(t('archives.toast.photoDeleted')); } catch { showToast(t('archives.toast.failedDeletePhoto'), 'error'); } }} /> )} {/* Project Page Modal */} {showProjectPage && ( setShowProjectPage(false)} /> )} {/* Schedule Modal */} {showSchedule && ( setShowSchedule(false)} /> )} {/* Hidden file input for source 3MF upload */} { const file = e.target.files?.[0]; if (file) { source3mfUploadMutation.mutate(file); } e.target.value = ''; }} /> {/* Hidden file input for F3D upload */} { const file = e.target.files?.[0]; if (file) { f3dUploadMutation.mutate(file); } e.target.value = ''; }} /> {/* Hidden file input for timelapse upload */} { const file = e.target.files?.[0]; if (file) { timelapseUploadMutation.mutate(file); } e.target.value = ''; }} /> ); } type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc'; type ViewMode = 'grid' | 'list' | 'calendar' | 'log'; type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'not-printed' | 'printed' | 'failed' | 'duplicates'; // status values that indicate a print attempt has finished (regardless of outcome). // `archived` is the only status that means "uploaded but never sent to a printer." const PRINTED_STATUSES = ['completed', 'failed', 'aborted', 'cancelled', 'stopped'] as const; const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [ { id: 'all', label: 'All Archives', icon: }, { id: 'recent', label: 'Last 24 Hours', icon: }, { id: 'this-week', label: 'This Week', icon: }, { id: 'this-month', label: 'This Month', icon: }, { id: 'favorites', label: 'Favorites', icon: }, { id: 'not-printed', label: 'Not Printed', icon: }, { id: 'printed', label: 'Printed', icon: }, { id: 'failed', label: 'Failed Prints', icon: }, { id: 'duplicates', label: 'Duplicates', icon: }, ]; export function ArchivesPage() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission, hasAnyPermission } = useAuth(); const searchInputRef = useRef(null); const [search, setSearch] = useState(''); const [filterPrinter, setFilterPrinter] = useState(() => { const saved = localStorage.getItem('archiveFilterPrinter'); return saved ? Number(saved) : null; }); const [filterMaterial, setFilterMaterial] = useState(() => localStorage.getItem('archiveFilterMaterial') ); const [filterColors, setFilterColors] = useState>(() => { const saved = localStorage.getItem('archiveFilterColors'); return saved ? new Set(JSON.parse(saved)) : new Set(); }); const [colorFilterMode, setColorFilterMode] = useState<'or' | 'and'>(() => (localStorage.getItem('archiveColorFilterMode') as 'or' | 'and') || 'or' ); const [filterFavorites, setFilterFavorites] = useState(() => localStorage.getItem('archiveFilterFavorites') === 'true' ); const [hideFailed, setHideFailed] = useState(() => localStorage.getItem('archiveHideFailed') === 'true' ); const [hideDuplicates, setHideDuplicates] = useState(() => localStorage.getItem('archiveHideDuplicates') === 'true' ); const [filterTag, setFilterTag] = useState(() => localStorage.getItem('archiveFilterTag') ); const [filterFileType, setFilterFileType] = useState<'all' | 'gcode' | 'source'>(() => (localStorage.getItem('archiveFilterFileType') as 'all' | 'gcode' | 'source') || 'all' ); const [showUpload, setShowUpload] = useState(false); const [uploadFiles, setUploadFiles] = useState([]); const [isDraggingOver, setIsDraggingOver] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [isSelectionMode, setIsSelectionMode] = useState(false); const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); const [showBatchTag, setShowBatchTag] = useState(false); const [showBatchProject, setShowBatchProject] = useState(false); const [viewMode, setViewMode] = useState(() => (localStorage.getItem('archiveViewMode') as ViewMode) || 'grid' ); const [sortBy, setSortBy] = useState(() => (localStorage.getItem('archiveSortBy') as SortOption) || 'date-desc' ); const [collection, setCollection] = useState(() => (localStorage.getItem('archiveCollection') as Collection) || 'all' ); // Pagination state const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(() => { try { const stored = localStorage.getItem('archivePageSize'); return stored ? Number(stored) : 50; } catch { return 50; } }); const [showExportMenu, setShowExportMenu] = useState(false); const [isExporting, setIsExporting] = useState(false); const [showCompareModal, setShowCompareModal] = useState(false); const [showTagManagement, setShowTagManagement] = useState(false); const [showPurgeModal, setShowPurgeModal] = useState(false); const [highlightedArchiveId, setHighlightedArchiveId] = useState(null); const [pendingNavigationArchiveId, setPendingNavigationArchiveId] = useState(null); // Log view state const [logFilterUser, setLogFilterUser] = useState(() => localStorage.getItem('logFilterUser') || null ); const [logFilterStatus, setLogFilterStatus] = useState(() => localStorage.getItem('logFilterStatus') ); const [logFilterDateFrom, setLogFilterDateFrom] = useState(() => localStorage.getItem('logFilterDateFrom') || '' ); const [logFilterDateTo, setLogFilterDateTo] = useState(() => localStorage.getItem('logFilterDateTo') || '' ); const [logOffset, setLogOffset] = useState(() => { const saved = localStorage.getItem('logOffset'); return saved ? Number(saved) : 0; }); const [showClearLogConfirm, setShowClearLogConfirm] = useState(false); const [logPageSize, setLogPageSize] = useState(() => { const saved = localStorage.getItem('logPageSize'); return saved ? Number(saved) : 25; }); const handleNavigateToArchive = useCallback((archiveId: number) => { setPendingNavigationArchiveId(archiveId); setHighlightedArchiveId(archiveId); }, []); // Clear highlight after 5 seconds and scroll to highlighted element useEffect(() => { if (highlightedArchiveId) { // Scroll to highlighted element after a short delay (to let the view render) const scrollTimer = setTimeout(() => { const element = document.querySelector(`[data-archive-id="${highlightedArchiveId}"]`); if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); } else if (pendingNavigationArchiveId === highlightedArchiveId) { showToast(t('archives.originalPrintNotVisible'), 'warning'); } if (pendingNavigationArchiveId === highlightedArchiveId) { setPendingNavigationArchiveId(null); } }, 100); // Clear highlight after 5 seconds const clearTimer = setTimeout(() => setHighlightedArchiveId(null), 5000); return () => { clearTimeout(scrollTimer); clearTimeout(clearTimer); }; } }, [highlightedArchiveId, pendingNavigationArchiveId, showToast, t]); const { data: archives, isLoading } = useQuery({ queryKey: ['archives', filterPrinter], queryFn: () => api.getArchives(filterPrinter || undefined), }); const { data: printers } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, }); const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: () => api.getProjects(), }); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, }); const { data: users } = useQuery({ queryKey: ['users'], queryFn: api.getUsers, enabled: viewMode === 'log', }); const { data: printLogData, isLoading: isLogLoading } = useQuery({ queryKey: ['print-log', filterPrinter, logFilterUser, logFilterStatus, logFilterDateFrom, logFilterDateTo, search, logOffset, logPageSize], queryFn: () => api.getPrintLog({ search: search || undefined, printerId: filterPrinter || undefined, username: logFilterUser || undefined, status: logFilterStatus || undefined, dateFrom: logFilterDateFrom || undefined, dateTo: logFilterDateTo || undefined, limit: logPageSize, offset: logOffset, }), enabled: viewMode === 'log', }); const timeFormat: TimeFormat = settings?.time_format || 'system'; const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio'; const useSlicerApi = settings?.use_slicer_api ?? false; const currency = getCurrencySymbol(settings?.currency || 'USD'); const bulkDeleteMutation = useMutation({ mutationFn: async (ids: number[]) => { await Promise.all(ids.map((id) => api.deleteArchive(id))); return ids.length; }, onSuccess: (count) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); setSelectedIds(new Set()); showToast(`${count} archive${count !== 1 ? 's' : ''} deleted`); }, onError: () => { showToast(t('archives.toast.failedDeleteArchives'), 'error'); }, }); const clearLogMutation = useMutation({ mutationFn: () => api.clearPrintLog(), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['print-log'] }); setLogOffset(0); showToast(t('archives.log.cleared', { count: data.deleted })); }, onError: () => { showToast(t('archives.log.clearFailed'), 'error'); }, }); // Persist all filters to localStorage useEffect(() => { if (filterPrinter !== null) { localStorage.setItem('archiveFilterPrinter', filterPrinter.toString()); } else { localStorage.removeItem('archiveFilterPrinter'); } }, [filterPrinter]); useEffect(() => { if (filterMaterial) { localStorage.setItem('archiveFilterMaterial', filterMaterial); } else { localStorage.removeItem('archiveFilterMaterial'); } }, [filterMaterial]); useEffect(() => { localStorage.setItem('archiveFilterColors', JSON.stringify([...filterColors])); }, [filterColors]); useEffect(() => { localStorage.setItem('archiveColorFilterMode', colorFilterMode); }, [colorFilterMode]); useEffect(() => { localStorage.setItem('archiveFilterFavorites', filterFavorites.toString()); }, [filterFavorites]); useEffect(() => { localStorage.setItem('archiveHideFailed', hideFailed.toString()); }, [hideFailed]); useEffect(() => { localStorage.setItem('archiveHideDuplicates', hideDuplicates.toString()); }, [hideDuplicates]); useEffect(() => { if (filterTag) { localStorage.setItem('archiveFilterTag', filterTag); } else { localStorage.removeItem('archiveFilterTag'); } }, [filterTag]); useEffect(() => { localStorage.setItem('archiveFilterFileType', filterFileType); }, [filterFileType]); // Reset page when filters/search/sort/collection change useEffect(() => { setPageIndex(0); }, [search, filterPrinter, filterMaterial, filterColors, colorFilterMode, filterFavorites, hideFailed, hideDuplicates, filterTag, filterFileType, sortBy, collection]); useEffect(() => { try { localStorage.setItem('archivePageSize', String(pageSize)); } catch { /* ignore */ } }, [pageSize]); useEffect(() => { localStorage.setItem('archiveViewMode', viewMode); }, [viewMode]); useEffect(() => { localStorage.setItem('archiveSortBy', sortBy); }, [sortBy]); useEffect(() => { localStorage.setItem('archiveCollection', collection); }, [collection]); // Persist log view filters useEffect(() => { if (logFilterUser) { localStorage.setItem('logFilterUser', logFilterUser); } else { localStorage.removeItem('logFilterUser'); } }, [logFilterUser]); useEffect(() => { if (logFilterStatus) { localStorage.setItem('logFilterStatus', logFilterStatus); } else { localStorage.removeItem('logFilterStatus'); } }, [logFilterStatus]); useEffect(() => { if (logFilterDateFrom) { localStorage.setItem('logFilterDateFrom', logFilterDateFrom); } else { localStorage.removeItem('logFilterDateFrom'); } }, [logFilterDateFrom]); useEffect(() => { if (logFilterDateTo) { localStorage.setItem('logFilterDateTo', logFilterDateTo); } else { localStorage.removeItem('logFilterDateTo'); } }, [logFilterDateTo]); useEffect(() => { localStorage.setItem('logOffset', logOffset.toString()); }, [logOffset]); useEffect(() => { localStorage.setItem('logPageSize', logPageSize.toString()); }, [logPageSize]); const printerMap = new Map(printers?.map((p) => [p.id, p.name]) || []); // Extract unique materials and colors from archives const uniqueMaterials = [...new Set( archives?.flatMap(a => a.filament_type?.split(', ') || []).filter(Boolean) || [] )].sort(); const uniqueColors = [...new Set( archives?.flatMap(a => a.filament_color?.split(',') || []).filter(Boolean) || [] )]; const uniqueTags = [...new Set( archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || [] )].sort(); const filteredArchives = archives ?.filter((a) => { // Collection filter const now = new Date(); const archiveDate = parseUTCDate(a.created_at) || new Date(0); let matchesCollection = true; switch (collection) { case 'recent': matchesCollection = (now.getTime() - archiveDate.getTime()) < 24 * 60 * 60 * 1000; break; case 'this-week': matchesCollection = (now.getTime() - archiveDate.getTime()) < 7 * 24 * 60 * 60 * 1000; break; case 'this-month': matchesCollection = archiveDate.getMonth() === now.getMonth() && archiveDate.getFullYear() === now.getFullYear(); break; case 'favorites': matchesCollection = a.is_favorite === true; break; case 'not-printed': matchesCollection = a.status === 'archived'; break; case 'printed': matchesCollection = (PRINTED_STATUSES as readonly string[]).includes(a.status); break; case 'failed': matchesCollection = a.status === 'failed' || a.status === 'aborted'; break; case 'duplicates': matchesCollection = a.duplicate_count > 0; break; } // Search filter const matchesSearch = (a.print_name || a.filename).toLowerCase().includes(search.toLowerCase()); // Material filter const matchesMaterial = !filterMaterial || (a.filament_type?.split(', ').includes(filterMaterial)); // Color filter (AND: must have all selected colors, OR: must have any selected color) const archiveColors = a.filament_color?.split(',') || []; const matchesColor = filterColors.size === 0 || (colorFilterMode === 'or' ? archiveColors.some(c => filterColors.has(c)) : [...filterColors].every(c => archiveColors.includes(c))); // Favorites filter (only apply if not using favorites collection) const matchesFavorites = collection === 'favorites' || !filterFavorites || a.is_favorite; // Hide failed filter (don't apply when viewing failed collection) const matchesHideFailed = collection === 'failed' || !hideFailed || (a.status !== 'failed' && a.status !== 'aborted'); // Hide duplicates filter (don't apply when viewing duplicates collection) const matchesHideDuplicates = collection === 'duplicates' || !hideDuplicates || a.duplicate_count === 0 || a.duplicate_sequence === 0; // Tag filter const archiveTags = a.tags?.split(',').map(t => t.trim()) || []; const matchesTag = !filterTag || archiveTags.includes(filterTag); // File type filter (gcode = sliced, source = project file only) const isGcodeFile = isSlicedFile(a); const matchesFileType = filterFileType === 'all' || (filterFileType === 'gcode' && isGcodeFile) || (filterFileType === 'source' && !isGcodeFile); return matchesCollection && matchesSearch && matchesMaterial && matchesColor && matchesFavorites && matchesHideFailed && matchesHideDuplicates && matchesTag && matchesFileType; }) .sort((a, b) => { switch (sortBy) { case 'date-desc': return (parseUTCDate(b.created_at)?.getTime() || 0) - (parseUTCDate(a.created_at)?.getTime() || 0); case 'date-asc': return (parseUTCDate(a.created_at)?.getTime() || 0) - (parseUTCDate(b.created_at)?.getTime() || 0); case 'name-asc': return (a.print_name || a.filename).localeCompare(b.print_name || b.filename); case 'name-desc': return (b.print_name || b.filename).localeCompare(a.print_name || a.filename); case 'size-desc': return b.file_size - a.file_size; case 'size-asc': return a.file_size - b.file_size; default: return 0; } }); // Pagination const totalFiltered = filteredArchives?.length || 0; const showAll = pageSize === -1; const effectivePageSize = showAll ? totalFiltered || 1 : pageSize; const totalPages = Math.max(1, Math.ceil(totalFiltered / effectivePageSize)); const paginatedArchives = showAll ? filteredArchives : filteredArchives?.slice(pageIndex * effectivePageSize, (pageIndex + 1) * effectivePageSize); // Jump to the page containing the highlighted archive useEffect(() => { if (highlightedArchiveId && filteredArchives && !showAll) { const idx = filteredArchives.findIndex(a => a.id === highlightedArchiveId); if (idx >= 0) { const targetPage = Math.floor(idx / effectivePageSize); if (targetPage !== pageIndex) setPageIndex(targetPage); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [highlightedArchiveId]); const selectionMode = isSelectionMode || selectedIds.size > 0; const toggleSelect = (id: number) => { setSelectedIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }; const selectAll = () => { if (filteredArchives) { setSelectedIds(new Set(filteredArchives.map((a) => a.id))); } }; const clearSelection = () => { setSelectedIds(new Set()); setIsSelectionMode(false); }; const toggleColor = (color: string) => { setFilterColors((prev) => { const next = new Set(prev); if (next.has(color)) { next.delete(color); } else { next.add(color); } return next; }); }; const clearColorFilter = () => { setFilterColors(new Set()); }; const clearTopFilters = () => { setSearch(''); setFilterPrinter(null); setFilterMaterial(null); setFilterFavorites(false); setHideFailed(false); setHideDuplicates(false); setFilterTag(null); setFilterFileType('all'); }; const hasTopFilters = search || filterPrinter || filterMaterial || filterFavorites || hideFailed || hideDuplicates || filterTag || filterFileType !== 'all'; // Drag & drop handlers for page-wide upload const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes('Files')) { setIsDraggingOver(true); } }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); // Only hide if leaving the page (not entering a child) if (e.currentTarget === e.target) { setIsDraggingOver(false); } }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDraggingOver(false); const droppedFiles = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.3mf')); if (droppedFiles.length > 0) { setUploadFiles(droppedFiles); setShowUpload(true); } else if (e.dataTransfer.files.length > 0) { showToast(t('archives.page.only3mfSupported'), 'warning'); } }, [showToast, t]); // Keyboard shortcuts const handleKeyDown = useCallback((e: KeyboardEvent) => { const target = e.target as HTMLElement; // Ignore if typing in an input/textarea if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { if (e.key === 'Escape') { target.blur(); } return; } switch (e.key) { case '/': e.preventDefault(); searchInputRef.current?.focus(); break; case 'u': case 'U': if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); setShowUpload(true); } break; case 'Escape': if (selectionMode) { clearSelection(); } break; } }, [selectionMode]); useEffect(() => { document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [handleKeyDown]); return (
{/* Drag & Drop Overlay */} {isDraggingOver && (

Drop .3mf files here

{t('archives.releaseToUpload')}

)} {/* Selection Toolbar */} {selectionMode && (
{selectedIds.size} selected
)}

Archives

{filteredArchives?.length || 0} of {archives?.length || 0} prints

{/* Export dropdown */}
{showExportMenu && (
)}
{/* Compare button (only when 2-5 items selected) */} {selectedIds.size >= 2 && selectedIds.size <= 5 && ( )} {!selectionMode && ( )} {hasPermission('archives:purge') && ( )}
{/* View mode toggle — always visible */}
{/* Filters (hidden in log view which has its own filters) */} {viewMode !== 'log' &&
{/* Search - full width on mobile */}
setSearch(e.target.value)} />
{/* Filters - horizontal scroll on mobile */}
{uniqueTags.length > 0 && (
)}
{hasTopFilters && ( )}
{/* Color Filter */} {uniqueColors.length > 0 && (
Colors: {filterColors.size > 1 && ( )}
{uniqueColors.map((color) => (
{filterColors.size > 0 && ( )}
)}
} {/* Pending Uploads Panel (visible when in queue mode with pending files) */} {/* Archives */} {isLoading ? (
{t('archives.loadingArchives')}
) : filteredArchives?.length === 0 ? (

{search ? t('archives.noArchivesSearch') : t('archives.noArchivesYet')}

Archives are created automatically when prints complete

) : viewMode === 'calendar' ? ( { // Switch to grid view and highlight the archive setSearch(''); // Clear search to show all archives setViewMode('grid'); setHighlightedArchiveId(archive.id); }} highlightedArchiveId={highlightedArchiveId} /> ) : viewMode === 'grid' ? ( <>
{paginatedArchives?.map((archive) => ( ))}
{ setPageSize(size); setPageIndex(0); }} t={t} /> ) : viewMode === 'list' ? ( <>
{/* List Header */}
Name
Printer
Date
Size
Actions
{/* List Items */} {paginatedArchives?.map((archive) => ( ))}
{ setPageSize(size); setPageIndex(0); }} t={t} /> ) : viewMode === 'log' ? (
{/* Log filters */}
{/* Search */}
{ setSearch(e.target.value); setLogOffset(0); }} />
{/* Printer filter */} {/* User filter */} {/* Status filter */} {/* Date range */}
{ setLogFilterDateFrom(e.target.value); setLogOffset(0); }} />
{ setLogFilterDateTo(e.target.value); setLogOffset(0); }} />
{/* Clear log button */}
{/* Log table */} {isLogLoading ? (
) : !printLogData?.items.length ? (
{t('archives.log.noEntries')}
) : ( <>
{printLogData.items.map((entry) => ( ))}
{t('archives.log.date')} {t('archives.log.printName')} {t('archives.log.printer')} {t('archives.log.user')} {t('archives.log.status')} {t('archives.log.duration')} {t('archives.log.filament')}
{formatDateTime(entry.started_at || entry.created_at, timeFormat)}
{entry.thumbnail_path && ( { (e.target as HTMLImageElement).style.display = 'none'; }} /> )} {entry.print_name || '—'}
{entry.printer_name || '—'} {entry.created_by_username || '—'} {entry.status} {entry.duration_seconds ? formatDuration(entry.duration_seconds) : '—'}
{entry.filament_color && ( )} {entry.filament_type || '—'}
{/* Pagination */}
{t('archives.log.showing', { count: Math.min(logOffset + logPageSize, printLogData.total), total: printLogData.total })}
{t('archives.log.page')} {Math.floor(logOffset / logPageSize) + 1} / {Math.max(1, Math.ceil(printLogData.total / logPageSize))}
)}
) : null} {/* Upload Modal */} {showUpload && ( { setShowUpload(false); setUploadFiles([]); }} initialFiles={uploadFiles} /> )} {/* Archive bulk-purge modal (#1008 follow-up) */} {showPurgeModal && ( setShowPurgeModal(false)} /> )} {/* Bulk Delete Confirmation */} {showBulkDeleteConfirm && ( { bulkDeleteMutation.mutate(Array.from(selectedIds)); setShowBulkDeleteConfirm(false); }} onCancel={() => setShowBulkDeleteConfirm(false)} /> )} {/* Batch Tag Modal */} {showBatchTag && ( setShowBatchTag(false)} /> )} {/* Batch Project Modal */} {showBatchProject && ( setShowBatchProject(false)} /> )} {/* Compare Archives Modal */} {showCompareModal && selectedIds.size >= 2 && selectedIds.size <= 5 && ( { setShowCompareModal(false); setSelectedIds(new Set()); setIsSelectionMode(false); }} /> )} {/* Tag Management Modal */} {showTagManagement && ( setShowTagManagement(false)} /> )} {/* Clear Log Confirmation */} {showClearLogConfirm && ( { clearLogMutation.mutate(); setShowClearLogConfirm(false); }} onCancel={() => setShowClearLogConfirm(false)} /> )}
); } /* Pagination bar for archives grid/list views */ function ArchivePaginationBar({ pageIndex, pageSize, totalRows, totalPages, onPageChange, onPageSizeChange, t, }: { pageIndex: number; pageSize: number; totalRows: number; totalPages: number; onPageChange: (page: number) => void; onPageSizeChange: (size: number) => void; t: (key: string) => string; }) { const isShowAll = pageSize === -1; if (totalPages <= 1 && !isShowAll) return null; const effectiveSize = isShowAll ? totalRows || 1 : pageSize; return (
{isShowAll ? `${totalRows} ${t('archives.prints')}` : <>{t('archives.pagination.showing')} {pageIndex * effectiveSize + 1} {t('archives.pagination.to')}{' '} {Math.min((pageIndex + 1) * effectiveSize, totalRows)}{' '} {t('archives.pagination.of')} {totalRows} {t('archives.prints')} }
{t('archives.pagination.show')} {!isShowAll && ( <> {t('archives.pagination.page')} {pageIndex + 1} {t('archives.pagination.of')} {totalPages} )}
); }