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
+28
View File
@@ -2,6 +2,34 @@
All notable changes to Bambuddy will be documented in this file.
## [0.1.6b9] - 2026-01-09
### Added
- **Archive list view improvements** - Full feature parity with card view:
- Edit and delete buttons inline with each row
- Three-dot menu button for context menu access
- All context menu actions (re-print, compare, add to project, etc.)
- **Archive object count** - Shows number of printable objects on archive cards:
- Displays in stats grid (e.g., "3 objs")
- Extracted from 3MF metadata automatically
- **Cross-view archive highlight** - Click an archive in calendar or project view to highlight it:
- Switches to card/list view and scrolls to the archive
- Yellow border highlight for 5 seconds
- Works in card, list, and calendar views
- **Context menu visual indicator** - Three-dot button on cards and list items:
- Shows on hover (desktop) or always visible (mobile)
- Provides quick access to context menu actions
- Positioned on left side for easy access
- **Spoolman location clearing** - When spools are removed from AMS, their location field is now cleared in Spoolman:
- Previously, location persisted even after spool removal
- Now correctly clears "Printer Name - AMS X Slot Y" when spool is no longer present
### Fixed
- **QR code endpoint** - Fixed 500 error on archive QR code generation:
- Added `qrcode[pil]` to requirements.txt
- Improved error handling for missing dependencies
- Fixed PIL Image resizing method
## [0.1.6b8] - 2026-01-08
### Added
+12 -5
View File
@@ -1464,16 +1464,20 @@ async def get_qrcode(
db: AsyncSession = Depends(get_db),
):
"""Generate a QR code that links to this archive."""
import qrcode
try:
import qrcode
from PIL import Image as PILImage
except ImportError:
raise HTTPException(500, "QR code generation not available - qrcode package not installed")
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
archive = result.scalar_one_or_none()
if not archive:
raise HTTPException(404, "Archive not found")
# Build URL to archive detail page
# Build URL to archive download
base_url = str(request.base_url).rstrip("/")
archive_url = f"{base_url}/archives?id={archive_id}"
archive_url = f"{base_url}/api/v1/archives/{archive_id}/download"
# Generate QR code
qr = qrcode.QRCode(
@@ -1487,13 +1491,16 @@ async def get_qrcode(
img = qr.make_image(fill_color="black", back_color="white")
# Convert to PIL Image for resizing
pil_img = img.get_image()
# Resize if needed
if size != 200:
img = img.resize((size, size))
pil_img = pil_img.resize((size, size), PILImage.Resampling.LANCZOS)
# Convert to bytes
buffer = io.BytesIO()
img.save(buffer, format="PNG")
pil_img.save(buffer, format="PNG")
buffer.seek(0)
return Response(
+13 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, model_validator
class ArchiveBase(BaseModel):
@@ -45,6 +45,9 @@ class ArchiveResponse(BaseModel):
duplicates: list[ArchiveDuplicate] | None = None
duplicate_count: int = 0 # Quick count for list views
# Object count (computed from extra_data.printable_objects)
object_count: int | None = None
print_name: str | None
print_time_seconds: int | None # Estimated time from slicer
actual_time_seconds: int | None = None # Computed from started_at/completed_at
@@ -81,6 +84,15 @@ class ArchiveResponse(BaseModel):
created_at: datetime
@model_validator(mode="after")
def compute_object_count(self) -> "ArchiveResponse":
"""Compute object_count from extra_data.printable_objects if not set."""
if self.object_count is None and self.extra_data:
printable_objects = self.extra_data.get("printable_objects")
if printable_objects and isinstance(printable_objects, dict):
self.object_count = len(printable_objects)
return self
class Config:
from_attributes = True
+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>
+3
View File
@@ -31,6 +31,9 @@ pywebpush>=2.0.0
python-multipart>=0.0.6
aiofiles>=23.0.0
# QR Code generation
qrcode[pil]>=7.4.0
# System monitoring
psutil>=6.0.0
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BstVVW2U.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DIdbNfsf.css">
<script type="module" crossorigin src="/assets/index-OrUoRy29.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BEtulymk.css">
</head>
<body>
<div id="root"></div>