import { useState, useRef, useEffect, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Download, Trash2, Clock, Package, 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, Loader2, FolderKanban, } from 'lucide-react'; import { api } from '../api/client'; import { openInSlicer } from '../utils/slicer'; import { formatDate, formatDateOnly, parseUTCDate } from '../utils/date'; import { useIsMobile } from '../hooks/useIsMobile'; import type { Archive, ProjectListItem } from '../api/client'; import { Card, CardContent } from '../components/Card'; import { Button } from '../components/Button'; import { ModelViewerModal } from '../components/ModelViewerModal'; import { ReprintModal } from '../components/ReprintModal'; import { UploadModal } from '../components/UploadModal'; import { ConfirmModal } from '../components/ConfirmModal'; import { EditArchiveModal } from '../components/EditArchiveModal'; 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 { AddToQueueModal } from '../components/AddToQueueModal'; import { CompareArchivesModal } from '../components/CompareArchivesModal'; import { PendingUploadsPanel } from '../components/PendingUploadsPanel'; import { useToast } from '../contexts/ToastContext'; function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function formatDuration(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (hours > 0) return `${hours}h ${minutes}m`; return `${minutes}m`; } // formatDate imported from '../utils/date' - handles UTC conversion function ArchiveCard({ archive, printerName, isSelected, onSelect, selectionMode, projects, isHighlighted, }: { archive: Archive; printerName: string; isSelected: boolean; onSelect: (id: number) => void; selectionMode: boolean; projects: ProjectListItem[] | undefined; isHighlighted?: boolean; }) { // Debug: log when card is highlighted if (isHighlighted) { console.log('ArchiveCard isHighlighted=true for archive:', archive.id); } const queryClient = useQueryClient(); const { showToast } = useToast(); const isMobile = useIsMobile(); const [showViewer, setShowViewer] = useState(false); const [showReprint, setShowReprint] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showEdit, setShowEdit] = 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 [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const source3mfInputRef = useRef(null); const source3mfUploadMutation = useMutation({ mutationFn: (file: File) => api.uploadSource3mf(archive.id, file), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(`Source 3MF attached: ${data.filename}`); }, onError: (error: Error) => { showToast(error.message || 'Failed to upload source 3MF', 'error'); }, }); const source3mfDeleteMutation = useMutation({ mutationFn: () => api.deleteSource3mf(archive.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast('Source 3MF removed'); }, onError: (error: Error) => { showToast(error.message || 'Failed to remove source 3MF', 'error'); }, }); const timelapseScanMutation = useMutation({ mutationFn: () => api.scanArchiveTimelapse(archive.id), onSuccess: (data) => { if (data.status === 'attached') { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(`Timelapse attached: ${data.filename}`); } else if (data.status === 'exists') { showToast('Timelapse already attached'); } 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 || 'No matching timelapse found', 'warning'); } }, onError: (error: Error) => { showToast(error.message || 'Failed to scan for timelapse', 'error'); }, }); const timelapseSelectMutation = useMutation({ mutationFn: (filename: string) => api.selectArchiveTimelapse(archive.id, filename), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(`Timelapse attached: ${data.filename}`); setShowTimelapseSelect(false); setAvailableTimelapses([]); }, onError: (error: Error) => { showToast(error.message || 'Failed to attach timelapse', 'error'); }, }); const deleteMutation = useMutation({ mutationFn: () => api.deleteArchive(archive.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast('Archive deleted'); }, onError: () => { showToast('Failed to delete archive', 'error'); }, }); const favoriteMutation = useMutation({ mutationFn: () => api.toggleFavorite(archive.id), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(data.is_favorite ? 'Added to favorites' : 'Removed from favorites'); }, }); const assignProjectMutation = useMutation({ mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); queryClient.invalidateQueries({ queryKey: ['projects'] }); showToast('Project updated'); }, onError: () => { showToast('Failed to update project', 'error'); }, }); const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }; const isGcodeFile = archive.filename?.toLowerCase().includes('.gcode.'); const contextMenuItems: ContextMenuItem[] = [ // For gcode files: show Print option // For source files: show Slice as the primary action ...(isGcodeFile ? [ { label: 'Print', icon: , onClick: () => setShowReprint(true), }, { label: 'Schedule', icon: , onClick: () => setShowSchedule(true), }, { label: 'Open in Bambu Studio', icon: , onClick: () => { const filename = archive.print_name || archive.filename || 'model'; const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`; openInSlicer(downloadUrl); }, }, ] : [ { label: 'Slice', icon: , onClick: () => { const filename = archive.print_name || archive.filename || 'model'; const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`; openInSlicer(downloadUrl); }, }, ]), { label: 'View on MakerWorld', icon: , onClick: () => archive.makerworld_url && window.open(archive.makerworld_url, '_blank'), disabled: !archive.makerworld_url, }, { label: '', divider: true, onClick: () => {} }, { label: '3D Preview', icon: , onClick: () => setShowViewer(true), }, { label: 'View Timelapse', icon: , onClick: () => setShowTimelapse(true), disabled: !archive.timelapse_path, }, { label: 'Scan for Timelapse', icon: , onClick: () => timelapseScanMutation.mutate(), disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending, }, { label: '', divider: true, onClick: () => {} }, { label: archive.source_3mf_path ? 'Download Source 3MF' : 'Upload Source 3MF', icon: , onClick: () => { if (archive.source_3mf_path) { const link = document.createElement('a'); link.href = api.getSource3mfDownloadUrl(archive.id); link.download = `${archive.print_name || archive.filename}_source.3mf`; link.click(); } else { source3mfInputRef.current?.click(); } }, }, ...(archive.source_3mf_path ? [{ label: 'Replace Source 3MF', icon: , onClick: () => source3mfInputRef.current?.click(), }, { label: 'Remove Source 3MF', icon: , onClick: () => setShowDeleteSource3mfConfirm(true), danger: true, }] : []), { label: '', divider: true, onClick: () => {} }, { label: 'Download', icon: , onClick: () => { const link = document.createElement('a'); link.href = api.getArchiveDownload(archive.id); link.download = `${archive.print_name || archive.filename}.3mf`; link.click(); }, }, { label: 'Copy Download Link', icon: , onClick: () => { const url = `${window.location.origin}${api.getArchiveDownload(archive.id)}`; navigator.clipboard.writeText(url).then(() => { showToast('Link copied to clipboard'); }).catch(() => { showToast('Failed to copy link', 'error'); }); }, }, { label: 'QR Code', icon: , onClick: () => setShowQRCode(true), }, { label: `View Photos${archive.photos?.length ? ` (${archive.photos.length})` : ''}`, icon: , onClick: () => setShowPhotos(true), disabled: !archive.photos?.length, }, { label: 'Project Page', icon: , onClick: () => setShowProjectPage(true), }, { label: '', divider: true, onClick: () => {} }, { label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites', icon: , onClick: () => favoriteMutation.mutate(), }, { label: 'Edit', icon: , onClick: () => setShowEdit(true), }, ...(archive.project_id && archive.project_name ? [{ label: `Go to Project: ${archive.project_name}`, icon: , onClick: () => window.location.href = '/projects', }] : []), { label: 'Add to Project', icon: , onClick: () => {}, submenu: (() => { const items: ContextMenuItem[] = []; // Add "Remove from Project" if archive is in a project if (archive.project_id) { items.push({ label: 'Remove from Project', icon: , onClick: () => assignProjectMutation.mutate(null), }); } // Add project options if (!projects) { items.push({ label: 'Loading...', icon: , onClick: () => {}, disabled: true, }); } else { const activeProjects = projects.filter(p => p.status === 'active'); if (activeProjects.length === 0) { items.push({ label: 'No projects available', 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 ? 'Deselect' : 'Select', icon: isSelected ? : , onClick: () => onSelect(archive.id), }, { label: '', divider: true, onClick: () => {} }, { label: 'Delete', icon: , onClick: () => setShowDeleteConfirm(true), danger: true, }, ]; return ( onSelect(archive.id) : undefined} > {/* Selection checkbox */} {selectionMode && ( )} {/* Thumbnail */}
{archive.thumbnail_path ? ( {archive.print_name ) : (
)} {/* Context menu button - visible on mobile, shows on hover for desktop */} {/* Favorite star */} {(archive.status === 'failed' || archive.status === 'aborted') && (
{archive.status === 'aborted' ? 'cancelled' : 'failed'}
)} {/* Duplicate badge */} {archive.duplicate_count > 0 && (
duplicate
)} {/* Source 3MF badge */} {archive.source_3mf_path && ( )} {/* Timelapse badge */} {archive.timelapse_path && ( )} {/* Photos badge */} {archive.photos && archive.photos.length > 0 && ( )}
{/* Title */}

{archive.print_name || archive.filename}

{printerName}

{/* File type badge */} {archive.filename?.toLowerCase().includes('.gcode.') ? 'GCODE' : 'SOURCE'} {archive.project_name && ( p.id === archive.project_id)?.color || '#6b7280'}20`, color: projects?.find(p => p.id === archive.project_id)?.color || '#6b7280' }} title={`Project: ${archive.project_name}`} > {archive.project_name} )}
{/* 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.layer_height || archive.total_layers) && (
{archive.total_layers && {archive.total_layers} layers} {archive.total_layers && archive.layer_height && ·} {archive.layer_height && {archive.layer_height}mm}
)} {archive.object_count != null && archive.object_count > 0 && (
1 ? 's' : ''}`}> {archive.object_count} object{archive.object_count > 1 ? 's' : ''}
)} {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 */}
{formatDate(archive.created_at)} {formatFileSize(archive.file_size)}
{/* Actions */}
{archive.filename?.toLowerCase().includes('.gcode.') ? ( // Sliced file - can print directly <> ) : ( // Source file only - must open in slicer first )}
{/* Edit Modal */} {showEdit && ( setShowEdit(false)} /> )} {/* 3D Viewer Modal */} {showViewer && ( setShowViewer(false)} /> )} {/* Reprint Modal */} {showReprint && ( setShowReprint(false)} onSuccess={() => { // Could show a toast notification here }} /> )} {/* Delete Confirmation */} {showDeleteConfirm && ( { deleteMutation.mutate(); setShowDeleteConfirm(false); }} onCancel={() => setShowDeleteConfirm(false)} /> )} {/* Delete Source 3MF Confirmation */} {showDeleteSource3mfConfirm && ( { source3mfDeleteMutation.mutate(); setShowDeleteSource3mfConfirm(false); }} onCancel={() => setShowDeleteSource3mfConfirm(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 && (

Select Timelapse

No auto-match found. Select the timelapse for this print:

{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('Photo deleted'); } catch { showToast('Failed to delete photo', '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 = ''; }} /> ); } function ArchiveListRow({ archive, printerName, isSelected, onSelect, selectionMode, projects, isHighlighted, }: { archive: Archive; printerName: string; isSelected: boolean; onSelect: (id: number) => void; selectionMode: boolean; projects: ProjectListItem[] | undefined; isHighlighted?: boolean; }) { const queryClient = useQueryClient(); const { showToast } = useToast(); const [showEdit, setShowEdit] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showReprint, setShowReprint] = useState(false); const [showSchedule, setShowSchedule] = useState(false); const [showViewer, setShowViewer] = 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 [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const source3mfInputRef = useRef(null); const source3mfUploadMutation = useMutation({ mutationFn: (file: File) => api.uploadSource3mf(archive.id, file), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(`Source 3MF attached: ${data.filename}`); }, onError: (error: Error) => { showToast(error.message || 'Failed to upload source 3MF', 'error'); }, }); const source3mfDeleteMutation = useMutation({ mutationFn: () => api.deleteSource3mf(archive.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast('Source 3MF removed'); }, onError: (error: Error) => { showToast(error.message || 'Failed to remove source 3MF', 'error'); }, }); const timelapseScanMutation = useMutation({ mutationFn: () => api.scanArchiveTimelapse(archive.id), onSuccess: (data) => { if (data.status === 'attached') { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(`Timelapse attached: ${data.filename}`); } else if (data.status === 'exists') { showToast('Timelapse already attached'); } else if (data.status === 'not_found' && data.available_files && data.available_files.length > 0) { setAvailableTimelapses(data.available_files); setShowTimelapseSelect(true); } else { showToast(data.message || 'No matching timelapse found', 'warning'); } }, onError: (error: Error) => { showToast(error.message || 'Failed to scan for timelapse', 'error'); }, }); const timelapseSelectMutation = useMutation({ mutationFn: (filename: string) => api.selectArchiveTimelapse(archive.id, filename), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(`Timelapse attached: ${data.filename}`); setShowTimelapseSelect(false); setAvailableTimelapses([]); }, onError: (error: Error) => { showToast(error.message || 'Failed to attach timelapse', 'error'); }, }); const deleteMutation = useMutation({ mutationFn: () => api.deleteArchive(archive.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast('Archive deleted'); }, onError: () => { showToast('Failed to delete archive', 'error'); }, }); const favoriteMutation = useMutation({ mutationFn: () => api.toggleFavorite(archive.id), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['archives'] }); showToast(data.is_favorite ? 'Added to favorites' : 'Removed from favorites'); }, }); const assignProjectMutation = useMutation({ mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); queryClient.invalidateQueries({ queryKey: ['projects'] }); showToast('Project updated'); }, onError: () => { showToast('Failed to update project', 'error'); }, }); const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }; const isGcodeFile = archive.filename?.toLowerCase().includes('.gcode.'); const contextMenuItems: ContextMenuItem[] = [ ...(isGcodeFile ? [ { label: 'Print', icon: , onClick: () => setShowReprint(true), }, { label: 'Schedule', icon: , onClick: () => setShowSchedule(true), }, { label: 'Open in Bambu Studio', icon: , onClick: () => { const filename = archive.print_name || archive.filename || 'model'; const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`; openInSlicer(downloadUrl); }, }, ] : [ { label: 'Slice', icon: , onClick: () => { const filename = archive.print_name || archive.filename || 'model'; const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`; openInSlicer(downloadUrl); }, }, ]), { label: 'View on MakerWorld', icon: , onClick: () => archive.makerworld_url && window.open(archive.makerworld_url, '_blank'), disabled: !archive.makerworld_url, }, { label: '', divider: true, onClick: () => {} }, { label: '3D Preview', icon: , onClick: () => setShowViewer(true), }, { label: 'View Timelapse', icon: , onClick: () => setShowTimelapse(true), disabled: !archive.timelapse_path, }, { label: 'Scan for Timelapse', icon: , onClick: () => timelapseScanMutation.mutate(), disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending, }, { label: '', divider: true, onClick: () => {} }, { label: archive.source_3mf_path ? 'Download Source 3MF' : 'Upload Source 3MF', icon: , onClick: () => { if (archive.source_3mf_path) { const link = document.createElement('a'); link.href = api.getSource3mfDownloadUrl(archive.id); link.download = `${archive.print_name || archive.filename}_source.3mf`; link.click(); } else { source3mfInputRef.current?.click(); } }, }, ...(archive.source_3mf_path ? [{ label: 'Replace Source 3MF', icon: , onClick: () => source3mfInputRef.current?.click(), }, { label: 'Remove Source 3MF', icon: , onClick: () => setShowDeleteSource3mfConfirm(true), danger: true, }] : []), { label: '', divider: true, onClick: () => {} }, { label: 'Download', icon: , onClick: () => { const link = document.createElement('a'); link.href = api.getArchiveDownload(archive.id); link.download = `${archive.print_name || archive.filename}.3mf`; link.click(); }, }, { label: 'Copy Download Link', icon: , onClick: () => { const url = `${window.location.origin}${api.getArchiveDownload(archive.id)}`; navigator.clipboard.writeText(url).then(() => { showToast('Link copied to clipboard'); }).catch(() => { showToast('Failed to copy link', 'error'); }); }, }, { label: 'QR Code', icon: , onClick: () => setShowQRCode(true), }, { label: `View Photos${archive.photos?.length ? ` (${archive.photos.length})` : ''}`, icon: , onClick: () => setShowPhotos(true), disabled: !archive.photos?.length, }, { label: 'Project Page', icon: , onClick: () => setShowProjectPage(true), }, { label: '', divider: true, onClick: () => {} }, { label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites', icon: , onClick: () => favoriteMutation.mutate(), }, { label: 'Edit', icon: , onClick: () => setShowEdit(true), }, ...(archive.project_id && archive.project_name ? [{ label: `Go to Project: ${archive.project_name}`, icon: , onClick: () => window.location.href = '/projects', }] : []), { label: 'Add to Project', icon: , onClick: () => {}, submenu: (() => { const items: ContextMenuItem[] = []; if (archive.project_id) { items.push({ label: 'Remove from Project', icon: , onClick: () => assignProjectMutation.mutate(null), }); } if (!projects) { items.push({ label: 'Loading...', icon: , onClick: () => {}, disabled: true, }); } else { const activeProjects = projects.filter(p => p.status === 'active'); if (activeProjects.length === 0) { items.push({ label: 'No projects available', 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 ? 'Deselect' : 'Select', icon: isSelected ? : , onClick: () => onSelect(archive.id), }, { label: '', divider: true, onClick: () => {} }, { label: 'Delete', icon: , onClick: () => setShowDeleteConfirm(true), danger: true, }, ]; return ( <>
{selectionMode && ( )} {archive.thumbnail_path ? ( ) : (
)}

{archive.print_name || archive.filename}

{archive.timelapse_path && ( )}
{archive.filament_type && (
{archive.filament_type} {archive.filament_color && (
{archive.filament_color.split(',').map((color, i) => (
))}
)}
)}
{printerName}
{formatDateOnly(archive.created_at)}
{formatFileSize(archive.file_size)}
{archive.makerworld_url && ( )}
{/* Edit Modal */} {showEdit && ( setShowEdit(false)} /> )} {/* 3D Viewer Modal */} {showViewer && ( setShowViewer(false)} /> )} {/* Reprint Modal */} {showReprint && ( setShowReprint(false)} onSuccess={() => {}} /> )} {/* Delete Confirmation */} {showDeleteConfirm && ( { deleteMutation.mutate(); setShowDeleteConfirm(false); }} onCancel={() => setShowDeleteConfirm(false)} /> )} {/* Delete Source 3MF Confirmation */} {showDeleteSource3mfConfirm && ( { source3mfDeleteMutation.mutate(); setShowDeleteSource3mfConfirm(false); }} onCancel={() => setShowDeleteSource3mfConfirm(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 && (

Select Timelapse

No auto-match found. Select the timelapse for this print:

{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('Photo deleted'); } catch { showToast('Failed to delete photo', '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 = ''; }} /> ); } type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc'; type ViewMode = 'grid' | 'list' | 'calendar'; type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates'; 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: 'failed', label: 'Failed Prints', icon: }, { id: 'duplicates', label: 'Duplicates', icon: }, ]; export function ArchivesPage() { const queryClient = useQueryClient(); const { showToast } = useToast(); 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 [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' ); const [showExportMenu, setShowExportMenu] = useState(false); const [isExporting, setIsExporting] = useState(false); const [showCompareModal, setShowCompareModal] = useState(false); const [highlightedArchiveId, setHighlightedArchiveId] = useState(null); // 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' }); } }, 100); // Clear highlight after 5 seconds const clearTimer = setTimeout(() => setHighlightedArchiveId(null), 5000); return () => { clearTimeout(scrollTimer); clearTimeout(clearTimer); }; } }, [highlightedArchiveId]); 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 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('Failed to delete archives', '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(() => { if (filterTag) { localStorage.setItem('archiveFilterTag', filterTag); } else { localStorage.removeItem('archiveFilterTag'); } }, [filterTag]); useEffect(() => { localStorage.setItem('archiveFilterFileType', filterFileType); }, [filterFileType]); useEffect(() => { localStorage.setItem('archiveViewMode', viewMode); }, [viewMode]); useEffect(() => { localStorage.setItem('archiveSortBy', sortBy); }, [sortBy]); useEffect(() => { localStorage.setItem('archiveCollection', collection); }, [collection]); 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 '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'); // 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 = a.filename?.toLowerCase().includes('.gcode.'); const matchesFileType = filterFileType === 'all' || (filterFileType === 'gcode' && isGcodeFile) || (filterFileType === 'source' && !isGcodeFile); return matchesCollection && matchesSearch && matchesMaterial && matchesColor && matchesFavorites && matchesHideFailed && 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; } }); 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); setFilterTag(null); setFilterFileType('all'); }; const hasTopFilters = search || filterPrinter || filterMaterial || filterFavorites || hideFailed || 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('Only .3mf files are supported', 'warning'); } }, [showToast]); // 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

Release to upload

)} {/* 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 && ( )}
{/* Filters */}
{/* 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 ? (
Loading archives...
) : filteredArchives?.length === 0 ? (

{search ? 'No archives match your search' : 'No archives yet'}

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' ? (
{filteredArchives?.map((archive) => ( ))}
) : viewMode === 'list' ? (
{/* List Header */}
Name
Printer
Date
Size
Actions
{/* List Items */} {filteredArchives?.map((archive) => ( ))}
) : null} {/* Upload Modal */} {showUpload && ( { setShowUpload(false); setUploadFiles([]); }} initialFiles={uploadFiles} /> )} {/* Bulk Delete Confirmation */} {showBulkDeleteConfirm && ( 1 ? 's' : ''}? This action cannot be undone.`} confirmText={`Delete ${selectedIds.size}`} variant="danger" onConfirm={() => { 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); }} /> )}
); }