Archive improvements: list view actions, object count, cross-view highlighting

Added:
  - Archive list view: edit/delete buttons and context menu with full feature parity
  - Archive object count display on cards (extracted from 3MF metadata)
  - Cross-view archive highlighting: click in calendar/project to highlight in card view
  - Context menu button (⋮) on cards and list rows for easy access
  - Spoolman: clear location when spools are removed from AMS

  Fixed:
  - QR code endpoint 500 error (added qrcode[pil] dependency)
This commit is contained in:
maziggy
2026-01-09 08:24:14 +01:00
parent b4bc046684
commit e2b2877965
12 changed files with 1203 additions and 541 deletions
+1
View File
@@ -204,6 +204,7 @@ export interface Archive {
source_3mf_path: string | null;
duplicates: ArchiveDuplicate[] | null;
duplicate_count: number;
object_count: number | null;
print_name: string | null;
print_time_seconds: number | null;
actual_time_seconds: number | null; // Computed from started_at/completed_at
+25 -6
View File
@@ -6,6 +6,7 @@ import { api } from '../api/client';
interface CalendarViewProps {
archives: Archive[];
onArchiveClick?: (archive: Archive) => void;
highlightedArchiveId?: number | null;
}
function getDaysInMonth(year: number, month: number): number {
@@ -23,11 +24,12 @@ const MONTH_NAMES = [
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }: CalendarViewProps) {
const today = new Date();
const [currentMonth, setCurrentMonth] = useState(today.getMonth());
const [currentYear, setCurrentYear] = useState(today.getFullYear());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [selectedArchiveId, setSelectedArchiveId] = useState<number | null>(null);
// Group archives by date
const archivesByDate = useMemo(() => {
@@ -79,6 +81,14 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
const selectedArchives = selectedDate ? archivesByDate.get(selectedDate) || [] : [];
// Clear selected archive when date changes
const handleDateSelect = (dateKey: string | null) => {
if (dateKey !== selectedDate) {
setSelectedArchiveId(null);
}
setSelectedDate(dateKey);
};
return (
<div className="flex flex-col lg:flex-row gap-6">
{/* Calendar */}
@@ -137,7 +147,7 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
return (
<button
key={day}
onClick={() => setSelectedDate(isSelected ? null : dateKey)}
onClick={() => handleDateSelect(isSelected ? null : dateKey)}
className={`aspect-square rounded-lg p-1 flex flex-col items-center justify-center transition-colors relative ${
isSelected
? 'bg-bambu-green text-white'
@@ -216,11 +226,19 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
</h3>
{selectedArchives.length > 0 ? (
<div className="space-y-2 max-h-96 overflow-y-auto">
{selectedArchives.map(archive => (
{selectedArchives.map(archive => {
const isHighlighted = archive.id === selectedArchiveId || archive.id === highlightedArchiveId;
return (
<button
key={archive.id}
onClick={() => onArchiveClick?.(archive)}
className="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-left"
onClick={() => {
setSelectedArchiveId(archive.id);
onArchiveClick?.(archive);
}}
className={`w-full flex items-center gap-3 p-2 rounded-lg transition-colors text-left ${
!isHighlighted ? 'hover:bg-bambu-dark-tertiary' : ''
}`}
style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '2px' } : undefined}
>
{archive.thumbnail_path ? (
<img
@@ -255,7 +273,8 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
</div>
</div>
</button>
))}
);
})}
</div>
) : (
<p className="text-sm text-bambu-gray">No prints on this day</p>
+4 -3
View File
@@ -1,18 +1,19 @@
import type { ReactNode, MouseEvent } from 'react';
import type { ReactNode, MouseEvent, HTMLAttributes } from 'react';
interface CardProps {
interface CardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
className?: string;
onClick?: (e: MouseEvent) => void;
onContextMenu?: (e: MouseEvent) => void;
}
export function Card({ children, className = '', onClick, onContextMenu }: CardProps) {
export function Card({ children, className = '', onClick, onContextMenu, ...rest }: CardProps) {
return (
<div
className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${className}`}
onClick={onClick}
onContextMenu={onContextMenu}
{...rest}
>
{children}
</div>
+709 -118
View File
@@ -94,6 +94,7 @@ function ArchiveCard({
onSelect,
selectionMode,
projects,
isHighlighted,
}: {
archive: Archive;
printerName: string;
@@ -101,7 +102,13 @@ function ArchiveCard({
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();
@@ -417,7 +424,9 @@ function ArchiveCard({
return (
<Card
className={`relative flex flex-col ${isSelected ? 'ring-2 ring-bambu-green' : ''} ${selectionMode ? 'cursor-pointer' : ''}`}
data-archive-id={archive.id}
className={`relative flex flex-col group ${isSelected ? 'ring-2 ring-bambu-green' : ''} ${selectionMode ? 'cursor-pointer' : ''}`}
style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '2px' } : undefined}
onContextMenu={handleContextMenu}
onClick={selectionMode ? () => onSelect(archive.id) : undefined}
>
@@ -448,20 +457,20 @@ function ArchiveCard({
<Image className="w-12 h-12 text-bambu-dark-tertiary" />
</div>
)}
{/* Mobile menu button */}
{isMobile && (
<button
className="absolute top-2 right-10 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-colors"
onClick={(e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setContextMenu({ x: rect.left, y: rect.bottom + 4 });
}}
title="Menu"
>
<MoreVertical className="w-5 h-5 text-white" />
</button>
)}
{/* Context menu button - visible on mobile, shows on hover for desktop */}
<button
className={`absolute top-2 left-2 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-all ${
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
} ${selectionMode ? 'left-10' : ''}`}
onClick={(e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setContextMenu({ x: rect.left, y: rect.bottom + 4 });
}}
title="Right-click for more options"
>
<MoreVertical className="w-5 h-5 text-white" />
</button>
{/* Favorite star */}
<button
className="absolute top-2 right-2 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors"
@@ -614,6 +623,12 @@ function ArchiveCard({
{archive.layer_height && <span>{archive.layer_height}mm</span>}
</div>
)}
{archive.object_count != null && archive.object_count > 0 && (
<div className="flex items-center gap-1.5 text-bambu-gray" title={`${archive.object_count} object${archive.object_count > 1 ? 's' : ''}`}>
<Box className="w-3 h-3" />
{archive.object_count} object{archive.object_count > 1 ? 's' : ''}
</div>
)}
{archive.filament_type && (
<div className="flex items-center gap-1.5 col-span-2">
<span className="text-bambu-gray text-xs">{archive.filament_type}</span>
@@ -966,6 +981,650 @@ function ArchiveCard({
);
}
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<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
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<HTMLInputElement>(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: <Printer className="w-4 h-4" />,
onClick: () => setShowReprint(true),
},
{
label: 'Schedule',
icon: <Calendar className="w-4 h-4" />,
onClick: () => setShowSchedule(true),
},
{
label: 'Open in Bambu Studio',
icon: <ExternalLink className="w-4 h-4" />,
onClick: () => {
const filename = archive.print_name || archive.filename || 'model';
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
openInSlicer(downloadUrl);
},
},
] : [
{
label: 'Slice',
icon: <ExternalLink className="w-4 h-4" />,
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: <Globe className="w-4 h-4" />,
onClick: () => archive.makerworld_url && window.open(archive.makerworld_url, '_blank'),
disabled: !archive.makerworld_url,
},
{ label: '', divider: true, onClick: () => {} },
{
label: '3D Preview',
icon: <Box className="w-4 h-4" />,
onClick: () => setShowViewer(true),
},
{
label: 'View Timelapse',
icon: <Film className="w-4 h-4" />,
onClick: () => setShowTimelapse(true),
disabled: !archive.timelapse_path,
},
{
label: 'Scan for Timelapse',
icon: <ScanSearch className="w-4 h-4" />,
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: <FileCode className="w-4 h-4" />,
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: <Upload className="w-4 h-4" />,
onClick: () => source3mfInputRef.current?.click(),
},
{
label: 'Remove Source 3MF',
icon: <Trash2 className="w-4 h-4" />,
onClick: () => setShowDeleteSource3mfConfirm(true),
danger: true,
}] : []),
{ label: '', divider: true, onClick: () => {} },
{
label: 'Download',
icon: <Download className="w-4 h-4" />,
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: <Copy className="w-4 h-4" />,
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: <QrCode className="w-4 h-4" />,
onClick: () => setShowQRCode(true),
},
{
label: `View Photos${archive.photos?.length ? ` (${archive.photos.length})` : ''}`,
icon: <Camera className="w-4 h-4" />,
onClick: () => setShowPhotos(true),
disabled: !archive.photos?.length,
},
{
label: 'Project Page',
icon: <FileText className="w-4 h-4" />,
onClick: () => setShowProjectPage(true),
},
{ label: '', divider: true, onClick: () => {} },
{
label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
onClick: () => favoriteMutation.mutate(),
},
{
label: 'Edit',
icon: <Pencil className="w-4 h-4" />,
onClick: () => setShowEdit(true),
},
...(archive.project_id && archive.project_name ? [{
label: `Go to Project: ${archive.project_name}`,
icon: <FolderKanban className="w-4 h-4 text-bambu-green" />,
onClick: () => window.location.href = '/projects',
}] : []),
{
label: 'Add to Project',
icon: <FolderKanban className="w-4 h-4" />,
onClick: () => {},
submenu: (() => {
const items: ContextMenuItem[] = [];
if (archive.project_id) {
items.push({
label: 'Remove from Project',
icon: <X className="w-4 h-4" />,
onClick: () => assignProjectMutation.mutate(null),
});
}
if (!projects) {
items.push({
label: 'Loading...',
icon: <Loader2 className="w-4 h-4 animate-spin" />,
onClick: () => {},
disabled: true,
});
} else {
const activeProjects = projects.filter(p => p.status === 'active');
if (activeProjects.length === 0) {
items.push({
label: 'No projects available',
icon: <FolderKanban className="w-4 h-4 opacity-50" />,
onClick: () => {},
disabled: true,
});
} else {
activeProjects.forEach(p => {
items.push({
label: p.name,
icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
onClick: () => assignProjectMutation.mutate(p.id),
disabled: archive.project_id === p.id,
});
});
}
}
return items;
})(),
},
{
label: isSelected ? 'Deselect' : 'Select',
icon: isSelected ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />,
onClick: () => onSelect(archive.id),
},
{ label: '', divider: true, onClick: () => {} },
{
label: 'Delete',
icon: <Trash2 className="w-4 h-4" />,
onClick: () => setShowDeleteConfirm(true),
danger: true,
},
];
return (
<>
<div
data-archive-id={archive.id}
className={`grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-bambu-dark-tertiary/30 ${
isSelected ? 'bg-bambu-green/10' : ''
}`}
style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '-4px' } : undefined}
onContextMenu={handleContextMenu}
>
<div className="col-span-1 flex items-center gap-2">
{selectionMode && (
<button onClick={() => onSelect(archive.id)}>
{isSelected ? (
<CheckSquare className="w-4 h-4 text-bambu-green" />
) : (
<Square className="w-4 h-4 text-bambu-gray" />
)}
</button>
)}
{archive.thumbnail_path ? (
<img
src={api.getArchiveThumbnail(archive.id)}
alt=""
className="w-10 h-10 object-cover rounded"
/>
) : (
<div className="w-10 h-10 bg-bambu-dark rounded flex items-center justify-center">
<Image className="w-5 h-5 text-bambu-dark-tertiary" />
</div>
)}
</div>
<div className="col-span-4">
<div className="flex items-center gap-2">
<p className="text-white text-sm truncate">{archive.print_name || archive.filename}</p>
{archive.timelapse_path && (
<span title="Has timelapse">
<Film className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
</span>
)}
</div>
{archive.filament_type && (
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-bambu-gray">{archive.filament_type}</span>
{archive.filament_color && (
<div className="flex items-center gap-0.5 flex-wrap">
{archive.filament_color.split(',').map((color, i) => (
<div
key={i}
className="w-2.5 h-2.5 rounded-full border border-white/20"
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
)}
</div>
)}
</div>
<div className="col-span-2 text-sm text-bambu-gray truncate">
{printerName}
</div>
<div className="col-span-2 text-sm text-bambu-gray">
{new Date(archive.created_at).toLocaleDateString()}
</div>
<div className="col-span-1 text-sm text-bambu-gray">
{formatFileSize(archive.file_size)}
</div>
<div className="col-span-2 flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
const filename = archive.print_name || archive.filename || 'model';
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
openInSlicer(downloadUrl);
}}
title="Open in Bambu Studio"
>
<ExternalLink className="w-4 h-4" />
</Button>
{archive.makerworld_url && (
<Button
variant="ghost"
size="sm"
onClick={() => window.open(archive.makerworld_url!, '_blank')}
title="MakerWorld"
>
<Globe className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => {
const link = document.createElement('a');
link.href = api.getArchiveDownload(archive.id);
link.download = `${archive.print_name || archive.filename}.3mf`;
link.click();
}}
title="Download"
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowEdit(true)}
title="Edit"
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
title="Delete"
>
<Trash2 className="w-4 h-4 text-red-400" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setContextMenu({ x: rect.left, y: rect.bottom + 4 });
}}
title="More options"
>
<MoreVertical className="w-4 h-4" />
</Button>
</div>
</div>
{/* Edit Modal */}
{showEdit && (
<EditArchiveModal
archive={archive}
onClose={() => setShowEdit(false)}
/>
)}
{/* 3D Viewer Modal */}
{showViewer && (
<ModelViewerModal
archiveId={archive.id}
title={archive.print_name || archive.filename}
onClose={() => setShowViewer(false)}
/>
)}
{/* Reprint Modal */}
{showReprint && (
<ReprintModal
archiveId={archive.id}
archiveName={archive.print_name || archive.filename}
onClose={() => setShowReprint(false)}
onSuccess={() => {}}
/>
)}
{/* Delete Confirmation */}
{showDeleteConfirm && (
<ConfirmModal
title="Delete Archive"
message={`Are you sure you want to delete "${archive.print_name || archive.filename}"? This action cannot be undone.`}
confirmText="Delete"
variant="danger"
onConfirm={() => {
deleteMutation.mutate();
setShowDeleteConfirm(false);
}}
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
{/* Delete Source 3MF Confirmation */}
{showDeleteSource3mfConfirm && (
<ConfirmModal
title="Remove Source 3MF"
message={`Are you sure you want to remove the source 3MF file from "${archive.print_name || archive.filename}"?`}
confirmText="Remove"
variant="danger"
onConfirm={() => {
source3mfDeleteMutation.mutate();
setShowDeleteSource3mfConfirm(false);
}}
onCancel={() => setShowDeleteSource3mfConfirm(false)}
/>
)}
{/* Context Menu */}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={contextMenuItems}
onClose={() => setContextMenu(null)}
/>
)}
{/* Timelapse Viewer Modal */}
{showTimelapse && archive.timelapse_path && (
<TimelapseViewer
src={api.getArchiveTimelapse(archive.id)}
title={`${archive.print_name || archive.filename} - Timelapse`}
downloadFilename={`${archive.print_name || archive.filename}_timelapse.mp4`}
archiveId={archive.id}
onClose={() => setShowTimelapse(false)}
onEdit={() => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
setShowTimelapse(false);
}}
/>
)}
{/* Timelapse Selection Modal */}
{showTimelapseSelect && availableTimelapses.length > 0 && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
<div className="bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<div>
<h3 className="text-lg font-semibold text-white">Select Timelapse</h3>
<p className="text-sm text-gray-400 mt-1">
No auto-match found. Select the timelapse for this print:
</p>
</div>
<button
onClick={() => {
setShowTimelapseSelect(false);
setAvailableTimelapses([]);
}}
className="text-gray-400 hover:text-white p-1"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="overflow-y-auto flex-1 p-2">
{availableTimelapses.map((file) => (
<button
key={file.name}
onClick={() => timelapseSelectMutation.mutate(file.name)}
disabled={timelapseSelectMutation.isPending}
className="w-full text-left p-3 rounded-lg hover:bg-gray-700 transition-colors mb-1"
>
<div className="font-medium text-white">{file.name}</div>
<div className="text-sm text-gray-400 flex gap-3">
<span>{formatFileSize(file.size)}</span>
{file.mtime && (
<span>{new Date(file.mtime).toLocaleDateString()}</span>
)}
</div>
</button>
))}
</div>
</div>
</div>
)}
{/* QR Code Modal */}
{showQRCode && (
<QRCodeModal
archiveId={archive.id}
archiveName={archive.print_name || archive.filename}
onClose={() => setShowQRCode(false)}
/>
)}
{/* Photo Gallery Modal */}
{showPhotos && archive.photos && (
<PhotoGalleryModal
archiveId={archive.id}
archiveName={archive.print_name || archive.filename}
photos={archive.photos}
onClose={() => 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 && (
<ProjectPageModal
archiveId={archive.id}
archiveName={archive.print_name || archive.filename}
onClose={() => setShowProjectPage(false)}
/>
)}
{/* Schedule Modal */}
{showSchedule && (
<AddToQueueModal
archiveId={archive.id}
archiveName={archive.print_name || archive.filename}
onClose={() => setShowSchedule(false)}
/>
)}
{/* Hidden file input for source 3MF upload */}
<input
ref={source3mfInputRef}
type="file"
accept=".3mf"
className="hidden"
onChange={(e) => {
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';
@@ -1030,6 +1689,27 @@ export function ArchivesPage() {
const [showExportMenu, setShowExportMenu] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [showCompareModal, setShowCompareModal] = useState(false);
const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(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],
@@ -1746,10 +2426,12 @@ export function ArchivesPage() {
<CalendarView
archives={filteredArchives || []}
onArchiveClick={(archive) => {
// Switch to grid view and search for the archive
setSearch(archive.print_name || archive.filename);
// Switch to grid view and highlight the archive
setSearch(''); // Clear search to show all archives
setViewMode('grid');
setHighlightedArchiveId(archive.id);
}}
highlightedArchiveId={highlightedArchiveId}
/>
</Card>
) : viewMode === 'grid' ? (
@@ -1763,6 +2445,7 @@ export function ArchivesPage() {
onSelect={toggleSelect}
selectionMode={selectionMode}
projects={projects}
isHighlighted={archive.id === highlightedArchiveId}
/>
))}
</div>
@@ -1780,108 +2463,16 @@ export function ArchivesPage() {
</div>
{/* List Items */}
{filteredArchives?.map((archive) => (
<div
<ArchiveListRow
key={archive.id}
className={`grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-bambu-dark-tertiary/30 ${
selectedIds.has(archive.id) ? 'bg-bambu-green/10' : ''
}`}
>
<div className="col-span-1 flex items-center gap-2">
{selectionMode && (
<button onClick={() => toggleSelect(archive.id)}>
{selectedIds.has(archive.id) ? (
<CheckSquare className="w-4 h-4 text-bambu-green" />
) : (
<Square className="w-4 h-4 text-bambu-gray" />
)}
</button>
)}
{archive.thumbnail_path ? (
<img
src={api.getArchiveThumbnail(archive.id)}
alt=""
className="w-10 h-10 object-cover rounded"
/>
) : (
<div className="w-10 h-10 bg-bambu-dark rounded flex items-center justify-center">
<Image className="w-5 h-5 text-bambu-dark-tertiary" />
</div>
)}
</div>
<div className="col-span-4">
<div className="flex items-center gap-2">
<p className="text-white text-sm truncate">{archive.print_name || archive.filename}</p>
{archive.timelapse_path && (
<span title="Has timelapse">
<Film className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
</span>
)}
</div>
{archive.filament_type && (
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-bambu-gray">{archive.filament_type}</span>
{archive.filament_color && (
<div className="flex items-center gap-0.5 flex-wrap">
{archive.filament_color.split(',').map((color, i) => (
<div
key={i}
className="w-2.5 h-2.5 rounded-full border border-white/20"
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
)}
</div>
)}
</div>
<div className="col-span-2 text-sm text-bambu-gray truncate">
{archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
</div>
<div className="col-span-2 text-sm text-bambu-gray">
{new Date(archive.created_at).toLocaleDateString()}
</div>
<div className="col-span-1 text-sm text-bambu-gray">
{formatFileSize(archive.file_size)}
</div>
<div className="col-span-2 flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
const filename = archive.print_name || archive.filename || 'model';
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
openInSlicer(downloadUrl);
}}
title="Slice"
>
<ExternalLink className="w-4 h-4" />
</Button>
{archive.makerworld_url && (
<Button
variant="ghost"
size="sm"
onClick={() => window.open(archive.makerworld_url!, '_blank')}
title="MakerWorld"
>
<Globe className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => {
const link = document.createElement('a');
link.href = api.getArchiveDownload(archive.id);
link.download = `${archive.print_name || archive.filename}.3mf`;
link.click();
}}
title="Download"
>
<Download className="w-4 h-4" />
</Button>
</div>
</div>
archive={archive}
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
isSelected={selectedIds.has(archive.id)}
onSelect={toggleSelect}
selectionMode={selectionMode}
projects={projects}
isHighlighted={archive.id === highlightedArchiveId}
/>
))}
</div>
</Card>