Bladeren bron

Add checkboxes to select plates to queue (#777)

  Multi-plate 3MF files now support selecting a subset of plates to queue
  via checkboxes, instead of the binary "one plate" or "all plates" toggle.
  In add-to-queue mode, each plate gets a checkbox for multi-select with a
  Select All / Deselect All toggle. Reprint and edit modes remain single-select.
maziggy 2 maanden geleden
bovenliggende
commit
1b3faca4c7

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.3b1] - Unreleased
 ## [0.2.3b1] - Unreleased
 
 
 ### New Features
 ### New Features
+- **Select Plates to Queue** ([#777](https://github.com/maziggy/bambuddy/issues/777)) — Multi-plate 3MF files now support selecting a subset of plates to queue, instead of only "one plate" or "all plates". In add-to-queue mode, each plate has a checkbox for multi-select, with a "Select All / Deselect All" toggle. Reprint and edit modes remain single-select. Requested by @stringham.
 - **Camera Image Rotation** ([#672](https://github.com/maziggy/bambuddy/issues/672)) — Added per-printer camera rotation (0°, 90°, 180°, 270°) for cameras mounted in portrait or upside-down orientations. Configurable in Settings → Camera for each printer. Rotation applies to live stream, embedded viewer, stream overlay, and notification snapshots. Requested by @wrenoud.
 - **Camera Image Rotation** ([#672](https://github.com/maziggy/bambuddy/issues/672)) — Added per-printer camera rotation (0°, 90°, 180°, 270°) for cameras mounted in portrait or upside-down orientations. Configurable in Settings → Camera for each printer. Rotation applies to live stream, embedded viewer, stream overlay, and notification snapshots. Requested by @wrenoud.
 - **Per-User Email Notifications** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — When Advanced Authentication is enabled, individual users can now receive email notifications for their own print jobs. A new "Notifications" page lets each user toggle notifications for print start, complete, failed, and stopped events. Only prints submitted by that user trigger their email — other users' prints are not affected. Requires SMTP to be configured and the "User Notifications" toggle enabled in Settings → Notifications. Administrators and Operators have access by default; Viewers do not. Contributed by @cadtoolbox.
 - **Per-User Email Notifications** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — When Advanced Authentication is enabled, individual users can now receive email notifications for their own print jobs. A new "Notifications" page lets each user toggle notifications for print start, complete, failed, and stopped events. Only prints submitted by that user trigger their email — other users' prints are not affected. Requires SMTP to be configured and the "User Notifications" toggle enabled in Settings → Notifications. Administrators and Operators have access by default; Viewers do not. Contributed by @cadtoolbox.
 
 

+ 61 - 15
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -713,7 +713,7 @@ describe('PrintModal', () => {
     });
     });
   });
   });
 
 
-  describe('queue all plates', () => {
+  describe('multi-plate selection', () => {
     const multiPlateResponse = {
     const multiPlateResponse = {
       is_multi_plate: true,
       is_multi_plate: true,
       plates: [
       plates: [
@@ -731,7 +731,7 @@ describe('PrintModal', () => {
       );
       );
     });
     });
 
 
-    it('shows "Queue All" button only in add-to-queue mode', async () => {
+    it('shows "Select All" button only in add-to-queue mode', async () => {
       render(
       render(
         <PrintModal
         <PrintModal
           mode="add-to-queue"
           mode="add-to-queue"
@@ -742,11 +742,11 @@ describe('PrintModal', () => {
       );
       );
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Queue All 3 Plates')).toBeInTheDocument();
+        expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
       });
       });
     });
     });
 
 
-    it('does not show "Queue All" button in reprint mode', async () => {
+    it('does not show "Select All" button in reprint mode', async () => {
       render(
       render(
         <PrintModal
         <PrintModal
           mode="reprint"
           mode="reprint"
@@ -760,10 +760,10 @@ describe('PrintModal', () => {
       await waitFor(() => {
       await waitFor(() => {
         expect(screen.getByText('Plate 1')).toBeInTheDocument();
         expect(screen.getByText('Plate 1')).toBeInTheDocument();
       });
       });
-      expect(screen.queryByText('Queue All 3 Plates')).not.toBeInTheDocument();
+      expect(screen.queryByText('Select All 3 Plates')).not.toBeInTheDocument();
     });
     });
 
 
-    it('highlights all plates when "Queue All" is clicked', async () => {
+    it('selects all plates when "Select All" is clicked', async () => {
       const user = userEvent.setup();
       const user = userEvent.setup();
       render(
       render(
         <PrintModal
         <PrintModal
@@ -775,19 +775,20 @@ describe('PrintModal', () => {
       );
       );
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Queue All 3 Plates')).toBeInTheDocument();
+        expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
       });
       });
 
 
-      await user.click(screen.getByText('Queue All 3 Plates'));
+      await user.click(screen.getByText('Select All 3 Plates'));
 
 
-      // All plates should show check marks
+      // All plates should be highlighted (green border)
       await waitFor(() => {
       await waitFor(() => {
-        const checks = document.querySelectorAll('.text-bambu-green.flex-shrink-0');
-        expect(checks.length).toBe(3);
+        const plateButtons = document.querySelectorAll('button[type="button"].border-bambu-green');
+        // 3 plate buttons + the "Deselect All" toggle button = 4 green-bordered buttons
+        expect(plateButtons.length).toBeGreaterThanOrEqual(3);
       });
       });
     });
     });
 
 
-    it('creates one queue item per plate when submitting with queue-all', async () => {
+    it('allows selecting a subset of plates to queue', async () => {
       const queueRequests: unknown[] = [];
       const queueRequests: unknown[] = [];
       server.use(
       server.use(
         http.post('/api/v1/queue/', async ({ request }) => {
         http.post('/api/v1/queue/', async ({ request }) => {
@@ -810,15 +811,60 @@ describe('PrintModal', () => {
 
 
       // Wait for plates and select a printer
       // Wait for plates and select a printer
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Queue All 3 Plates')).toBeInTheDocument();
+        expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
         expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
         expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
       });
       });
 
 
       // Select printer
       // Select printer
       await user.click(screen.getByText('X1 Carbon'));
       await user.click(screen.getByText('X1 Carbon'));
 
 
-      // Click queue all
-      await user.click(screen.getByText('Queue All 3 Plates'));
+      // Plate 1 is auto-selected. Click Plate 3 to add it (multi-select in add-to-queue mode)
+      await user.click(screen.getByText('Plate 3'));
+
+      // Submit — should queue plates 1 and 3
+      const submitButton = document.querySelector('button[type="submit"]') as HTMLElement;
+      await user.click(submitButton);
+
+      await waitFor(() => {
+        expect(queueRequests.length).toBe(2);
+      });
+
+      expect((queueRequests[0] as { plate_id: number }).plate_id).toBe(1);
+      expect((queueRequests[1] as { plate_id: number }).plate_id).toBe(3);
+    });
+
+    it('creates one queue item per plate when submitting with select-all', async () => {
+      const queueRequests: unknown[] = [];
+      server.use(
+        http.post('/api/v1/queue/', async ({ request }) => {
+          const body = await request.json();
+          queueRequests.push(body);
+          return HttpResponse.json({ id: queueRequests.length, status: 'pending' });
+        }),
+      );
+
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="MultiPlate.3mf"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      // Wait for plates and select a printer
+      await waitFor(() => {
+        expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      // Select printer
+      await user.click(screen.getByText('X1 Carbon'));
+
+      // Click select all
+      await user.click(screen.getByText('Select All 3 Plates'));
 
 
       // Find the submit button (type="submit") — distinct from the toggle button (type="button")
       // Find the submit button (type="submit") — distinct from the toggle button (type="button")
       const submitButton = document.querySelector('button[type="submit"]') as HTMLElement;
       const submitButton = document.querySelector('button[type="submit"]') as HTMLElement;

+ 64 - 54
frontend/src/components/PrintModal/PlateSelector.tsx

@@ -1,4 +1,4 @@
-import { Layers, Check, AlertTriangle } from 'lucide-react';
+import { Layers, Check, AlertTriangle, Square, CheckSquare } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import type { PlateSelectorProps } from './types';
 import type { PlateSelectorProps } from './types';
 import { formatDuration } from '../../utils/date';
 import { formatDuration } from '../../utils/date';
@@ -6,14 +6,17 @@ import { formatDuration } from '../../utils/date';
 /**
 /**
  * Plate selection grid for multi-plate 3MF files.
  * Plate selection grid for multi-plate 3MF files.
  * Shows thumbnails, names, objects, and print times for each plate.
  * Shows thumbnails, names, objects, and print times for each plate.
+ * In multi-select mode (add-to-queue), plates have checkboxes for selecting a subset.
+ * In single-select mode (reprint/edit), only one plate can be selected at a time.
  */
  */
 export function PlateSelector({
 export function PlateSelector({
   plates,
   plates,
   isMultiPlate,
   isMultiPlate,
-  selectedPlate,
-  onSelect,
-  queueAll,
-  onQueueAllChange,
+  selectedPlates,
+  onToggle,
+  onSelectAll,
+  onDeselectAll,
+  multiSelect,
 }: PlateSelectorProps) {
 }: PlateSelectorProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -22,76 +25,83 @@ export function PlateSelector({
     return null;
     return null;
   }
   }
 
 
+  const allSelected = selectedPlates.size === plates.length;
+
   return (
   return (
     <div className="mb-4">
     <div className="mb-4">
       <div className="flex items-center gap-2 mb-2">
       <div className="flex items-center gap-2 mb-2">
         <Layers className="w-4 h-4 text-bambu-gray" />
         <Layers className="w-4 h-4 text-bambu-gray" />
-        <span className="text-sm text-bambu-gray">Select Plate to Print</span>
-        {!selectedPlate && !queueAll && (
+        <span className="text-sm text-bambu-gray">Select Plate{multiSelect ? 's' : ''} to Print</span>
+        {selectedPlates.size === 0 && (
           <span className="text-xs text-orange-400 flex items-center gap-1">
           <span className="text-xs text-orange-400 flex items-center gap-1">
             <AlertTriangle className="w-3 h-3" />
             <AlertTriangle className="w-3 h-3" />
             Selection required
             Selection required
           </span>
           </span>
         )}
         )}
-        {onQueueAllChange && (
+        {multiSelect && onSelectAll && onDeselectAll && (
           <button
           <button
             type="button"
             type="button"
-            onClick={() => onQueueAllChange(!queueAll)}
+            onClick={allSelected ? onDeselectAll : onSelectAll}
             className={`ml-auto text-xs px-2 py-0.5 rounded-full border transition-colors ${
             className={`ml-auto text-xs px-2 py-0.5 rounded-full border transition-colors ${
-              queueAll
+              allSelected
                 ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
                 ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
                 : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-gray'
                 : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-gray'
             }`}
             }`}
           >
           >
-            {t('queue.queueAllPlates', { count: plates.length })}
+            {allSelected
+              ? t('queue.deselectAll')
+              : t('queue.selectAllPlates', { count: plates.length })}
           </button>
           </button>
         )}
         )}
       </div>
       </div>
       <div className="grid grid-cols-2 gap-2">
       <div className="grid grid-cols-2 gap-2">
-        {plates.map((plate) => (
-          <button
-            key={plate.index}
-            type="button"
-            onClick={() => {
-              if (queueAll && onQueueAllChange) {
-                onQueueAllChange(false);
-              }
-              onSelect(plate.index);
-            }}
-            className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
-              queueAll || selectedPlate === plate.index
-                ? 'border-bambu-green bg-bambu-green/10'
-                : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
-            }`}
-          >
-            {plate.has_thumbnail && plate.thumbnail_url != null ? (
-              <img
-                src={plate.thumbnail_url}
-                alt={`Plate ${plate.index}`}
-                className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
-              />
-            ) : (
-              <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
-                <Layers className="w-5 h-5 text-bambu-gray" />
+        {plates.map((plate) => {
+          const isSelected = selectedPlates.has(plate.index);
+          return (
+            <button
+              key={plate.index}
+              type="button"
+              onClick={() => onToggle(plate.index)}
+              className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
+                isSelected
+                  ? 'border-bambu-green bg-bambu-green/10'
+                  : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
+              }`}
+            >
+              {multiSelect && (
+                isSelected
+                  ? <CheckSquare className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                  : <Square className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+              )}
+              {plate.has_thumbnail && plate.thumbnail_url != null ? (
+                <img
+                  src={plate.thumbnail_url}
+                  alt={`Plate ${plate.index}`}
+                  className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
+                />
+              ) : (
+                <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
+                  <Layers className="w-5 h-5 text-bambu-gray" />
+                </div>
+              )}
+              <div className="min-w-0 flex-1">
+                <p className="text-sm text-white font-medium truncate">
+                  {plate.name || `Plate ${plate.index}`}
+                </p>
+                <p className="text-xs text-bambu-gray truncate">
+                  {plate.objects.length > 0
+                    ? plate.objects.slice(0, 3).join(', ') +
+                      (plate.objects.length > 3 ? '...' : '')
+                    : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
+                  {plate.print_time_seconds != null ? ` • ${formatDuration(plate.print_time_seconds)}` : ''}
+                </p>
               </div>
               </div>
-            )}
-            <div className="min-w-0 flex-1">
-              <p className="text-sm text-white font-medium truncate">
-                {plate.name || `Plate ${plate.index}`}
-              </p>
-              <p className="text-xs text-bambu-gray truncate">
-                {plate.objects.length > 0
-                  ? plate.objects.slice(0, 3).join(', ') +
-                    (plate.objects.length > 3 ? '...' : '')
-                  : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
-                {plate.print_time_seconds != null ? ` • ${formatDuration(plate.print_time_seconds)}` : ''}
-              </p>
-            </div>
-            {(queueAll || selectedPlate === plate.index) && (
-              <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
-            )}
-          </button>
-        ))}
+              {!multiSelect && isSelected && (
+                <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
+              )}
+            </button>
+          );
+        })}
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 42 - 21
frontend/src/components/PrintModal/index.tsx

@@ -76,15 +76,16 @@ export function PrintModal({
     return [];
     return [];
   });
   });
 
 
-  const [selectedPlate, setSelectedPlate] = useState<number | null>(() => {
-    if (mode === 'edit-queue-item' && queueItem) {
-      return queueItem.plate_id;
+  // Multi-select plates: in add-to-queue mode users can pick a subset of plates
+  const [selectedPlates, setSelectedPlates] = useState<Set<number>>(() => {
+    if (mode === 'edit-queue-item' && queueItem?.plate_id != null) {
+      return new Set([queueItem.plate_id]);
     }
     }
-    return null;
+    return new Set();
   });
   });
 
 
-  // Queue all plates at once (only for add-to-queue mode with multi-plate files)
-  const [queueAllPlates, setQueueAllPlates] = useState(false);
+  // Derived single-plate value for filament queries and single-select contexts
+  const selectedPlate = selectedPlates.size === 1 ? [...selectedPlates][0] : null;
 
 
   const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
   const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
     if (mode === 'edit-queue-item' && queueItem) {
     if (mode === 'edit-queue-item' && queueItem) {
@@ -322,10 +323,10 @@ export function PrintModal({
 
 
   // Auto-select first plate when plates load (single or multi-plate)
   // Auto-select first plate when plates load (single or multi-plate)
   useEffect(() => {
   useEffect(() => {
-    if (platesData?.plates && platesData.plates.length >= 1 && !selectedPlate) {
-      setSelectedPlate(platesData.plates[0].index);
+    if (platesData?.plates && platesData.plates.length >= 1 && selectedPlates.size === 0) {
+      setSelectedPlates(new Set([platesData.plates[0].index]));
     }
     }
-  }, [platesData, selectedPlate]);
+  }, [platesData, selectedPlates.size]);
 
 
   // Auto-select first printer when only one available
   // Auto-select first printer when only one available
   useEffect(() => {
   useEffect(() => {
@@ -539,7 +540,9 @@ export function PrintModal({
 
 
     setIsSubmitting(true);
     setIsSubmitting(true);
     // Calculate total API calls: plates × printers (or 1 for model-based)
     // Calculate total API calls: plates × printers (or 1 for model-based)
-    const platesToQueue = queueAllPlates ? plates : [null];
+    const platesToQueue = selectedPlates.size > 1
+      ? plates.filter(p => selectedPlates.has(p.index))
+      : [null];
     const totalCount = assignmentMode === 'model'
     const totalCount = assignmentMode === 'model'
       ? platesToQueue.length
       ? platesToQueue.length
       : selectedPrinters.length * platesToQueue.length;
       : selectedPrinters.length * platesToQueue.length;
@@ -673,7 +676,7 @@ export function PrintModal({
 
 
           try {
           try {
             if (mode === 'reprint') {
             if (mode === 'reprint') {
-              // Reprint mode - start print immediately (single plate only, queueAllPlates not available)
+              // Reprint mode - start print immediately (single plate only, multi-select not available)
               const printerMapping = getMappingForPrinter(printerId);
               const printerMapping = getMappingForPrinter(printerId);
               if (isLibraryFile) {
               if (isLibraryFile) {
                 await api.printLibraryFile(libraryFileId!, printerId, {
                 await api.printLibraryFile(libraryFileId!, printerId, {
@@ -760,11 +763,11 @@ export function PrintModal({
     // Model-based assignment only works in queue modes (not immediate reprint)
     // Model-based assignment only works in queue modes (not immediate reprint)
     if (assignmentMode === 'model' && mode === 'reprint') return false;
     if (assignmentMode === 'model' && mode === 'reprint') return false;
 
 
-    // For multi-plate files, need a selected plate (or queue-all)
-    if (isMultiPlate && !selectedPlate && !queueAllPlates) return false;
+    // For multi-plate files, need at least one plate selected
+    if (isMultiPlate && selectedPlates.size === 0) return false;
 
 
     return true;
     return true;
-  }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlate, queueAllPlates, isPending]);
+  }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlates.size, isPending]);
 
 
   // Modal title and action button text based on mode
   // Modal title and action button text based on mode
   const getModalConfig = () => {
   const getModalConfig = () => {
@@ -783,8 +786,8 @@ export function PrintModal({
     }
     }
     if (mode === 'add-to-queue') {
     if (mode === 'add-to-queue') {
       let submitText = t('queue.addToQueue');
       let submitText = t('queue.addToQueue');
-      if (queueAllPlates && plates.length > 1) {
-        submitText = t('queue.queueAllPlates', { count: plates.length });
+      if (selectedPlates.size > 1) {
+        submitText = t('queue.queueSelectedPlates', { count: selectedPlates.size });
       } else if (printerCount > 1) {
       } else if (printerCount > 1) {
         submitText = t('queue.queueToPrinters', { count: printerCount });
         submitText = t('queue.queueToPrinters', { count: printerCount });
       }
       }
@@ -818,7 +821,7 @@ export function PrintModal({
   // - Single printer selected
   // - Single printer selected
   // - For archives: plate is selected (for multi-plate) or not required (single-plate)
   // - For archives: plate is selected (for multi-plate) or not required (single-plate)
   // - For library files: always show (no plate selection)
   // - For library files: always show (no plate selection)
-  const showFilamentMapping = effectivePrinterId && !queueAllPlates && (
+  const showFilamentMapping = effectivePrinterId && selectedPlates.size <= 1 && (
     isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
     isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
   );
   );
 
 
@@ -869,10 +872,28 @@ export function PrintModal({
             <PlateSelector
             <PlateSelector
               plates={plates}
               plates={plates}
               isMultiPlate={isMultiPlate}
               isMultiPlate={isMultiPlate}
-              selectedPlate={selectedPlate}
-              onSelect={setSelectedPlate}
-              queueAll={queueAllPlates}
-              onQueueAllChange={mode === 'add-to-queue' ? setQueueAllPlates : undefined}
+              selectedPlates={selectedPlates}
+              onToggle={(plateIndex) => {
+                setSelectedPlates(prev => {
+                  const next = new Set(prev);
+                  if (mode === 'add-to-queue') {
+                    // Multi-select: toggle the plate
+                    if (next.has(plateIndex)) {
+                      next.delete(plateIndex);
+                    } else {
+                      next.add(plateIndex);
+                    }
+                  } else {
+                    // Single-select: replace selection
+                    next.clear();
+                    next.add(plateIndex);
+                  }
+                  return next;
+                });
+              }}
+              onSelectAll={mode === 'add-to-queue' ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined}
+              onDeselectAll={mode === 'add-to-queue' ? () => setSelectedPlates(new Set()) : undefined}
+              multiSelect={mode === 'add-to-queue'}
             />
             />
 
 
             {/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
             {/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}

+ 6 - 6
frontend/src/components/PrintModal/types.ts

@@ -148,12 +148,12 @@ export interface PrinterSelectorProps {
 export interface PlateSelectorProps {
 export interface PlateSelectorProps {
   plates: PlateInfo[];
   plates: PlateInfo[];
   isMultiPlate: boolean;
   isMultiPlate: boolean;
-  selectedPlate: number | null;
-  onSelect: (plateIndex: number) => void;
-  /** Whether "queue all plates" is active */
-  queueAll?: boolean;
-  /** Callback to toggle queue-all mode (only shown in add-to-queue mode) */
-  onQueueAllChange?: (queueAll: boolean) => void;
+  selectedPlates: Set<number>;
+  onToggle: (plateIndex: number) => void;
+  onSelectAll?: () => void;
+  onDeselectAll?: () => void;
+  /** Whether multi-select (checkboxes) is enabled — true in add-to-queue mode */
+  multiSelect?: boolean;
 }
 }
 
 
 /**
 /**

+ 3 - 1
frontend/src/i18n/locales/de.ts

@@ -786,7 +786,9 @@ export default {
     editQueueItem: 'Warteschlangeneintrag bearbeiten',
     editQueueItem: 'Warteschlangeneintrag bearbeiten',
     printToPrinters: 'Auf {{count}} Druckern drucken',
     printToPrinters: 'Auf {{count}} Druckern drucken',
     queueToPrinters: 'Zu {{count}} Druckern hinzufügen',
     queueToPrinters: 'Zu {{count}} Druckern hinzufügen',
-    queueAllPlates: 'Alle {{count}} Platten in die Warteschlange',
+    queueSelectedPlates: '{{count}} Platten in die Warteschlange',
+    selectAllPlates: 'Alle {{count}} Platten auswählen',
+    deselectAll: 'Alle abwählen',
     printQueued: 'Druck in Warteschlange',
     printQueued: 'Druck in Warteschlange',
     itemsQueued: '{{count}} Einträge in Warteschlange',
     itemsQueued: '{{count}} Einträge in Warteschlange',
     sending: 'Wird gesendet...',
     sending: 'Wird gesendet...',

+ 3 - 1
frontend/src/i18n/locales/en.ts

@@ -786,7 +786,9 @@ export default {
     editQueueItem: 'Edit Queue Item',
     editQueueItem: 'Edit Queue Item',
     printToPrinters: 'Print to {{count}} Printers',
     printToPrinters: 'Print to {{count}} Printers',
     queueToPrinters: 'Queue to {{count}} Printers',
     queueToPrinters: 'Queue to {{count}} Printers',
-    queueAllPlates: 'Queue All {{count}} Plates',
+    queueSelectedPlates: 'Queue {{count}} Plates',
+    selectAllPlates: 'Select All {{count}} Plates',
+    deselectAll: 'Deselect All',
     printQueued: 'Print queued',
     printQueued: 'Print queued',
     itemsQueued: '{{count}} items queued',
     itemsQueued: '{{count}} items queued',
     sending: 'Sending...',
     sending: 'Sending...',

+ 3 - 1
frontend/src/i18n/locales/fr.ts

@@ -786,7 +786,9 @@ export default {
     editQueueItem: 'Modifier l\'élément',
     editQueueItem: 'Modifier l\'élément',
     printToPrinters: 'Imprimer sur {{count}} imprimantes',
     printToPrinters: 'Imprimer sur {{count}} imprimantes',
     queueToPrinters: 'Ajouter à la file pour {{count}} imprimantes',
     queueToPrinters: 'Ajouter à la file pour {{count}} imprimantes',
-    queueAllPlates: 'Ajouter les {{count}} plaques à la file',
+    queueSelectedPlates: 'Ajouter {{count}} plaques à la file',
+    selectAllPlates: 'Sélectionner les {{count}} plaques',
+    deselectAll: 'Tout désélectionner',
     printQueued: 'Impression ajoutée à la file',
     printQueued: 'Impression ajoutée à la file',
     itemsQueued: '{{count}} éléments ajoutés à la file',
     itemsQueued: '{{count}} éléments ajoutés à la file',
     sending: 'Envoi...',
     sending: 'Envoi...',

+ 3 - 1
frontend/src/i18n/locales/it.ts

@@ -786,7 +786,9 @@ export default {
     editQueueItem: 'Modifica elemento coda',
     editQueueItem: 'Modifica elemento coda',
     printToPrinters: 'Stampa su {{count}} Stampanti',
     printToPrinters: 'Stampa su {{count}} Stampanti',
     queueToPrinters: 'Metti in coda su {{count}} Stampanti',
     queueToPrinters: 'Metti in coda su {{count}} Stampanti',
-    queueAllPlates: 'Metti in coda tutte le {{count}} piastre',
+    queueSelectedPlates: 'Metti in coda {{count}} piastre',
+    selectAllPlates: 'Seleziona tutte le {{count}} piastre',
+    deselectAll: 'Deseleziona tutto',
     printQueued: 'Stampa in coda',
     printQueued: 'Stampa in coda',
     itemsQueued: '{{count}} elementi in coda',
     itemsQueued: '{{count}} elementi in coda',
     sending: 'Invio...',
     sending: 'Invio...',

+ 3 - 1
frontend/src/i18n/locales/ja.ts

@@ -785,7 +785,9 @@ export default {
     editQueueItem: 'キューアイテムを編集',
     editQueueItem: 'キューアイテムを編集',
     printToPrinters: '{{count}}台のプリンターで印刷',
     printToPrinters: '{{count}}台のプリンターで印刷',
     queueToPrinters: '{{count}}台のプリンターでキュー追加',
     queueToPrinters: '{{count}}台のプリンターでキュー追加',
-    queueAllPlates: '全{{count}}プレートをキューに追加',
+    queueSelectedPlates: '{{count}}プレートをキューに追加',
+    selectAllPlates: '全{{count}}プレートを選択',
+    deselectAll: '全て解除',
     printQueued: 'キューに追加しました',
     printQueued: 'キューに追加しました',
     itemsQueued: '{{count}}件をキューに追加しました',
     itemsQueued: '{{count}}件をキューに追加しました',
     sending: '送信中...',
     sending: '送信中...',

+ 3 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -786,7 +786,9 @@ export default {
     editQueueItem: 'Editar Item da Fila',
     editQueueItem: 'Editar Item da Fila',
     printToPrinters: 'Imprimir para {{count}} Impressoras',
     printToPrinters: 'Imprimir para {{count}} Impressoras',
     queueToPrinters: 'Adicionar à Fila para {{count}} Impressoras',
     queueToPrinters: 'Adicionar à Fila para {{count}} Impressoras',
-    queueAllPlates: 'Adicionar todas as {{count}} placas à fila',
+    queueSelectedPlates: 'Adicionar {{count}} placas à fila',
+    selectAllPlates: 'Selecionar todas as {{count}} placas',
+    deselectAll: 'Desmarcar tudo',
     printQueued: 'Impressão adicionada à fila',
     printQueued: 'Impressão adicionada à fila',
     itemsQueued: '{{count}} itens adicionados à fila',
     itemsQueued: '{{count}} itens adicionados à fila',
     sending: 'Enviando...',
     sending: 'Enviando...',

+ 3 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -786,7 +786,9 @@ export default {
     editQueueItem: '编辑队列项目',
     editQueueItem: '编辑队列项目',
     printToPrinters: '打印到 {{count}} 台打印机',
     printToPrinters: '打印到 {{count}} 台打印机',
     queueToPrinters: '排队到 {{count}} 台打印机',
     queueToPrinters: '排队到 {{count}} 台打印机',
-    queueAllPlates: '将全部 {{count}} 个热床加入队列',
+    queueSelectedPlates: '将 {{count}} 个热床加入队列',
+    selectAllPlates: '选择全部 {{count}} 个热床',
+    deselectAll: '取消全选',
     printQueued: '已加入打印队列',
     printQueued: '已加入打印队列',
     itemsQueued: '{{count}} 个任务已加入队列',
     itemsQueued: '{{count}} 个任务已加入队列',
     sending: '发送中...',
     sending: '发送中...',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DUZN_weu.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-REte0XxX.js"></script>
+    <script type="module" crossorigin src="/assets/index-DUZN_weu.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DI9VPacx.css">
     <link rel="stylesheet" crossorigin href="/assets/index-DI9VPacx.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff