ソースを参照

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
maziggy 7 ヶ月 前
コミット
c203d9bf5b

+ 8 - 0
CHANGELOG.md

@@ -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 - 0
README.md

@@ -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

+ 40 - 0
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -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
+    });
+  });
 });

+ 21 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -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 - 0
frontend/src/api/client.ts

@@ -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
frontend/src/pages/ArchivesPage.tsx

@@ -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
frontend/src/pages/QueuePage.tsx

@@ -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"
             />

ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-DFuUL8IF.css


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-abyjQYWw.js


+ 2 - 2
static/index.html

@@ -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>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません