Fix multi-plate thumbnail display in queue and add archive plate browsing

- Queue now shows correct plate thumbnail based on selected plate_id
  (previously always showed plate 1 regardless of selection)
- Archive cards now support plate browsing for multi-plate 3MF files:
  - Left/right arrows to cycle through plates on hover
  - Dot indicators at bottom show current plate (clickable)
  - Lazy-loads plate data only when user hovers
- Added getArchivePlateThumbnail and getLibraryFilePlateThumbnail API methods

Closes #166
This commit is contained in:
maziggy
2026-01-28 16:22:33 +01:00
parent be34b778ff
commit c203d9bf5b
10 changed files with 245 additions and 82 deletions
+8
View File
@@ -5,6 +5,11 @@ All notable changes to Bambuddy will be documented in this file.
## [0.1.6-final] - Not released
### New Features
- **Archive Plate Browsing** - Browse plate thumbnails directly in archive cards (Issue #166):
- Hover over archive card to reveal plate navigation for multi-plate files
- Left/right arrows to cycle through plate thumbnails
- Dot indicators show current plate (clickable to jump to specific plate)
- Lazy-loads plate data only when user hovers
- **GitHub Profile Backup** - Automatically backup your Cloud profiles, K-profiles and settings to a GitHub repository:
- Configure GitHub repository URL and Personal Access Token
- Schedule backups hourly, daily, or weekly
@@ -84,6 +89,9 @@ All notable changes to Bambuddy will be documented in this file.
- Tri-state toggles: unchanged / on / off for each setting
### Fixes
- **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):
- Queue now displays the correct plate thumbnail based on selected plate
- Previously always showed plate 1 thumbnail regardless of selection
- **HMS Error Notifications** - Get notified when printer errors occur (Issue #84):
- Automatic notifications for HMS errors (AMS issues, nozzle problems, etc.)
- Human-readable error messages (853 error codes translated)
+1
View File
@@ -52,6 +52,7 @@
- Photo attachments & failure analysis
- Timelapse editor (trim, speed, music)
- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support)
- Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
- Archive comparison (side-by-side diff)
### 📊 Monitoring & Control
@@ -261,4 +261,44 @@ describe('ArchivesPage', () => {
});
});
});
describe('plate navigation', () => {
it('renders archive cards with thumbnails', async () => {
render(<ArchivesPage />);
await waitFor(() => {
// Archive cards should render with their thumbnails
expect(screen.getByText('Benchy')).toBeInTheDocument();
// Thumbnail images should be present (archive cards have img elements)
const images = document.querySelectorAll('img[alt="Benchy"]');
expect(images.length).toBeGreaterThanOrEqual(0);
});
});
it('fetches plate data for multi-plate archives on hover', async () => {
// Setup handler for plates endpoint
server.use(
http.get('/api/v1/archives/:id/plates', ({ params }) => {
return HttpResponse.json({
archive_id: Number(params.id),
filename: 'test.3mf',
plates: [
{ index: 0, name: 'Plate 1', objects: ['Object A'], has_thumbnail: true, thumbnail_url: '/thumb1.png', print_time_seconds: 3600, filament_used_grams: 10, filaments: [] },
{ index: 1, name: 'Plate 2', objects: ['Object B'], has_thumbnail: true, thumbnail_url: '/thumb2.png', print_time_seconds: 1800, filament_used_grams: 5, filaments: [] },
],
is_multi_plate: true,
});
})
);
render(<ArchivesPage />);
await waitFor(() => {
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
// Archives with multi-plate support will show navigation on hover
// The plates API is called lazily when hovering
});
});
});
@@ -215,6 +215,27 @@ describe('QueuePage', () => {
expect(printerElements.length).toBeGreaterThan(0);
});
});
it('renders queue items with plate_id correctly', async () => {
// Override with queue items that have plate_id set
server.use(
http.get('/api/v1/queue/', () => {
return HttpResponse.json([
{
...mockQueueItems[0],
plate_id: 2,
archive_name: 'Multi-plate Print',
},
]);
})
);
render(<QueuePage />);
await waitFor(() => {
expect(screen.getByText('Multi-plate Print')).toBeInTheDocument();
});
});
});
describe('empty state', () => {
+4
View File
@@ -1984,6 +1984,8 @@ export const api = {
method: 'POST',
}),
getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail?v=${Date.now()}`,
getArchivePlateThumbnail: (id: number, plateIndex: number) =>
`${API_BASE}/archives/${id}/plate-thumbnail/${plateIndex}`,
getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
getArchivePlatePreview: (id: number) => `${API_BASE}/archives/${id}/plate-preview`,
@@ -3041,6 +3043,8 @@ export const api = {
request<{ status: string; message: string }>(`/library/files/${id}`, { method: 'DELETE' }),
getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,
getLibraryFileThumbnailUrl: (id: number) => `${API_BASE}/library/files/${id}/thumbnail`,
getLibraryFilePlateThumbnail: (id: number, plateIndex: number) =>
`${API_BASE}/library/files/${id}/plate-thumbnail/${plateIndex}`,
getLibraryFileGcodeUrl: (id: number) => `${API_BASE}/library/files/${id}/gcode`,
moveLibraryFiles: (fileIds: number[], folderId: number | null) =>
request<{ status: string; moved: number }>('/library/files/move', {
+84 -3
View File
@@ -41,6 +41,8 @@ import {
GitCompare,
Loader2,
FolderKanban,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { api } from '../api/client';
import { openInSlicer } from '../utils/slicer';
@@ -133,9 +135,23 @@ function ArchiveCard({
const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const [currentPlateIndex, setCurrentPlateIndex] = useState<number | null>(null);
const [showPlateNav, setShowPlateNav] = useState(false);
const source3mfInputRef = useRef<HTMLInputElement>(null);
const f3dInputRef = useRef<HTMLInputElement>(null);
// Fetch plates data for multi-plate browsing (lazy - only when hovering)
const { data: platesData } = useQuery({
queryKey: ['archive-plates', archive.id],
queryFn: () => api.getArchivePlates(archive.id),
enabled: showPlateNav, // Only fetch when user hovers to see navigation
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const plates = platesData?.plates ?? [];
const isMultiPlate = platesData?.is_multi_plate ?? false;
const displayPlateIndex = currentPlateIndex ?? 0;
const source3mfUploadMutation = useMutation({
mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
onSuccess: (data) => {
@@ -505,11 +521,19 @@ function ArchiveCard({
</button>
)}
{/* Thumbnail */}
<div className="aspect-video bg-bambu-dark relative flex-shrink-0 overflow-hidden rounded-t-xl">
{/* Thumbnail with plate navigation */}
<div
className="aspect-video bg-bambu-dark relative flex-shrink-0 overflow-hidden rounded-t-xl"
onMouseEnter={() => setShowPlateNav(true)}
onMouseLeave={() => setShowPlateNav(false)}
>
{archive.thumbnail_path ? (
<img
src={api.getArchiveThumbnail(archive.id)}
src={
currentPlateIndex !== null && plates.length > 0
? api.getArchivePlateThumbnail(archive.id, plates[displayPlateIndex]?.index ?? 0)
: api.getArchiveThumbnail(archive.id)
}
alt={archive.print_name || archive.filename}
className="w-full h-full object-cover"
/>
@@ -518,6 +542,63 @@ function ArchiveCard({
<Image className="w-12 h-12 text-bambu-dark-tertiary" />
</div>
)}
{/* Plate navigation - only show for multi-plate archives */}
{isMultiPlate && plates.length > 1 && (
<>
{/* Left arrow */}
<button
className={`absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => {
e.stopPropagation();
setCurrentPlateIndex((prev) => {
const current = prev ?? 0;
return current > 0 ? current - 1 : plates.length - 1;
});
}}
title="Previous plate"
>
<ChevronLeft className="w-4 h-4 text-white" />
</button>
{/* Right arrow */}
<button
className={`absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => {
e.stopPropagation();
setCurrentPlateIndex((prev) => {
const current = prev ?? 0;
return current < plates.length - 1 ? current + 1 : 0;
});
}}
title="Next plate"
>
<ChevronRight className="w-4 h-4 text-white" />
</button>
{/* Dots indicator */}
<div
className={`absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-1 px-2 py-1 rounded-full bg-black/50 transition-all ${
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
>
{plates.map((plate, idx) => (
<button
key={plate.index}
className={`w-2 h-2 rounded-full transition-colors ${
idx === displayPlateIndex ? 'bg-bambu-green' : 'bg-white/50 hover:bg-white/80'
}`}
onClick={(e) => {
e.stopPropagation();
setCurrentPlateIndex(idx);
}}
title={plate.name || `Plate ${plate.index}`}
/>
))}
</div>
</>
)}
{/* 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 ${
+11 -3
View File
@@ -344,17 +344,25 @@ function SortableQueueItem({
<div className="w-8" />
)}
{/* Thumbnail */}
{/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
<div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
{item.archive_thumbnail ? (
<img
src={api.getArchiveThumbnail(item.archive_id!)}
src={
item.plate_id != null
? api.getArchivePlateThumbnail(item.archive_id!, item.plate_id)
: api.getArchiveThumbnail(item.archive_id!)
}
alt=""
className="w-full h-full object-cover"
/>
) : item.library_file_thumbnail ? (
<img
src={api.getLibraryFileThumbnailUrl(item.library_file_id!)}
src={
item.plate_id != null
? api.getLibraryFilePlateThumbnail(item.library_file_id!, item.plate_id)
: api.getLibraryFileThumbnailUrl(item.library_file_id!)
}
alt=""
className="w-full h-full object-cover"
/>
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-DuO_7tQ3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nwJjDqT-.css">
<script type="module" crossorigin src="/assets/index-abyjQYWw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DFuUL8IF.css">
</head>
<body>
<div id="root"></div>