Просмотр исходного кода

Add camera view controls: chamber light toggle & skip objects (fixes #291)

- Extract SkipObjectsModal from PrintersPage into reusable component
- Add chamber light toggle and skip objects buttons to EmbeddedCameraViewer
  and CameraPage header bars
- Add printerStatus query to camera views (React Query deduplicates with
  existing PrintersPage query)
- Fix camera/stop 401 when auth enabled: replace sendBeacon with
  fetch + keepalive + auth headers in both camera components
- Add camera.chamberLight i18n key (en, de, ja)
- Add AuthProvider to CameraPage test wrapper
maziggy 3 месяцев назад
Родитель
Сommit
8511ce2c47

+ 2 - 0
CHANGELOG.md

@@ -12,8 +12,10 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 ### Added
 ### Added
 - **Hostname Support for Printers** ([#290](https://github.com/maziggy/bambuddy/issues/290)) — Printers can now be added using hostnames (e.g., `printer.local`, `my-printer.home.lan`) in addition to IPv4 addresses. Updated backend validation, frontend forms, and all locale labels.
 - **Hostname Support for Printers** ([#290](https://github.com/maziggy/bambuddy/issues/290)) — Printers can now be added using hostnames (e.g., `printer.local`, `my-printer.home.lan`) in addition to IPv4 addresses. Updated backend validation, frontend forms, and all locale labels.
+- **Camera View Controls** ([#291](https://github.com/maziggy/bambuddy/issues/291)) — Added chamber light toggle and skip objects buttons to both embedded camera viewer and standalone camera page. Extracted skip objects modal into a reusable `SkipObjectsModal` component shared across PrintersPage and both camera views.
 
 
 ### Fixed
 ### Fixed
+- **Camera Stop 401 When Auth Enabled** — Camera stop requests (`sendBeacon`) failed with 401 Unauthorized when authentication was enabled because `sendBeacon` cannot send auth headers. Replaced with `fetch` + `keepalive: true` which supports Authorization headers while remaining reliable during page unload.
 - **Filament Usage Charts Inflated by Quantity Multiplier** ([#229](https://github.com/maziggy/bambuddy/issues/229)) — Daily, weekly, and filament-type charts were multiplying `filament_used_grams` by print quantity, even though the value already represents the total for the entire job. A 26-object print using 126g was counted as 3,276g. Removed the erroneous multiplier from three aggregations in `FilamentTrends.tsx`.
 - **Filament Usage Charts Inflated by Quantity Multiplier** ([#229](https://github.com/maziggy/bambuddy/issues/229)) — Daily, weekly, and filament-type charts were multiplying `filament_used_grams` by print quantity, even though the value already represents the total for the entire job. A 26-object print using 126g was counted as 3,276g. Removed the erroneous multiplier from three aggregations in `FilamentTrends.tsx`.
 - **Energy Cost Shows 0.00 in "Total Consumption" Mode** ([#284](https://github.com/maziggy/bambuddy/issues/284)) — Statistics Quick Stats showed 0.00 energy cost when Energy Display Mode was set to "Total Consumption" with Home Assistant smart plugs. The `homeassistant_service` was not configured with HA URL/token before querying plug energy data, causing it to silently return nothing.
 - **Energy Cost Shows 0.00 in "Total Consumption" Mode** ([#284](https://github.com/maziggy/bambuddy/issues/284)) — Statistics Quick Stats showed 0.00 energy cost when Energy Display Mode was set to "Total Consumption" with Home Assistant smart plugs. The `homeassistant_service` was not configured with HA URL/token before querying plug energy data, causing it to silently return nothing.
 
 

+ 8 - 5
frontend/src/__tests__/pages/CameraPage.test.tsx

@@ -11,6 +11,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { ThemeProvider } from '../../contexts/ThemeContext';
 import { ThemeProvider } from '../../contexts/ThemeContext';
 import { ToastProvider } from '../../contexts/ToastContext';
 import { ToastProvider } from '../../contexts/ToastContext';
+import { AuthProvider } from '../../contexts/AuthContext';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
 import i18n from '../../i18n';
 import i18n from '../../i18n';
 
 
@@ -44,11 +45,13 @@ function renderCameraPage(printerId: number) {
       <I18nextProvider i18n={i18n}>
       <I18nextProvider i18n={i18n}>
         <MemoryRouter initialEntries={[`/cameras/${printerId}`]}>
         <MemoryRouter initialEntries={[`/cameras/${printerId}`]}>
           <ThemeProvider>
           <ThemeProvider>
-            <ToastProvider>
-              <Routes>
-                <Route path="/cameras/:printerId" element={<CameraPage />} />
-              </Routes>
-            </ToastProvider>
+            <AuthProvider>
+              <ToastProvider>
+                <Routes>
+                  <Route path="/cameras/:printerId" element={<CameraPage />} />
+                </Routes>
+              </ToastProvider>
+            </AuthProvider>
           </ThemeProvider>
           </ThemeProvider>
         </MemoryRouter>
         </MemoryRouter>
       </I18nextProvider>
       </I18nextProvider>

+ 83 - 4
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -1,7 +1,12 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useState, useEffect, useRef, useCallback } from 'react';
-import { useQuery } from '@tanstack/react-query';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize } from 'lucide-react';
 import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize } from 'lucide-react';
-import { api } from '../api/client';
+import { api, getAuthToken } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
+import { ChamberLight } from './icons/ChamberLight';
+import { SkipObjectsModal, SkipObjectsIcon } from './SkipObjectsModal';
 
 
 interface EmbeddedCameraViewerProps {
 interface EmbeddedCameraViewerProps {
   printerId: number;
   printerId: number;
@@ -31,6 +36,11 @@ const DEFAULT_STATE: CameraState = {
 };
 };
 
 
 export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0, onClose }: EmbeddedCameraViewerProps) {
 export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0, onClose }: EmbeddedCameraViewerProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { hasPermission } = useAuth();
+
   // Printer-specific storage key
   // Printer-specific storage key
   const storageKey = `${STORAGE_KEY_PREFIX}${printerId}`;
   const storageKey = `${STORAGE_KEY_PREFIX}${printerId}`;
 
 
@@ -87,6 +97,8 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
   const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
   const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
   const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
   const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
 
 
+  const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
+
   // Fetch printer info
   // Fetch printer info
   const { data: printer } = useQuery({
   const { data: printer } = useQuery({
     queryKey: ['printer', printerId],
     queryKey: ['printer', printerId],
@@ -94,6 +106,39 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     enabled: printerId > 0,
     enabled: printerId > 0,
   });
   });
 
 
+  // Fetch printer status for light toggle and skip objects
+  const { data: status } = useQuery({
+    queryKey: ['printerStatus', printerId],
+    queryFn: () => api.getPrinterStatus(printerId),
+    refetchInterval: 30000,
+    enabled: printerId > 0,
+  });
+
+  // Chamber light mutation with optimistic update
+  const chamberLightMutation = useMutation({
+    mutationFn: (on: boolean) => api.setChamberLight(printerId, on),
+    onMutate: async (on) => {
+      await queryClient.cancelQueries({ queryKey: ['printerStatus', printerId] });
+      const previousStatus = queryClient.getQueryData(['printerStatus', printerId]);
+      queryClient.setQueryData(['printerStatus', printerId], (old: typeof status) => ({
+        ...old,
+        chamber_light: on,
+      }));
+      return { previousStatus };
+    },
+    onSuccess: (_, on) => {
+      showToast(`Chamber light ${on ? 'on' : 'off'}`);
+    },
+    onError: (error: Error, _, context) => {
+      if (context?.previousStatus) {
+        queryClient.setQueryData(['printerStatus', printerId], context.previousStatus);
+      }
+      showToast(error.message || t('printers.toast.failedToControlChamberLight'), 'error');
+    },
+  });
+
+  const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE' || status?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2;
+
   // Save state to localStorage (printer-specific)
   // Save state to localStorage (printer-specific)
   useEffect(() => {
   useEffect(() => {
     const saveTimeout = setTimeout(() => {
     const saveTimeout = setTimeout(() => {
@@ -111,7 +156,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     const sendStopOnce = () => {
     const sendStopOnce = () => {
       if (printerId > 0 && !stopSentRef.current) {
       if (printerId > 0 && !stopSentRef.current) {
         stopSentRef.current = true;
         stopSentRef.current = true;
-        navigator.sendBeacon(stopUrl);
+        const headers: Record<string, string> = {};
+        const token = getAuthToken();
+        if (token) headers['Authorization'] = `Bearer ${token}`;
+        fetch(stopUrl, { method: 'POST', keepalive: true, headers }).catch(() => {});
       }
       }
     };
     };
 
 
@@ -403,7 +451,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
     if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
     if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
     if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
 
 
-    fetch(`/api/v1/printers/${printerId}/camera/stop`).catch(() => {});
+    const stopHeaders: Record<string, string> = {};
+    const stopToken = getAuthToken();
+    if (stopToken) stopHeaders['Authorization'] = `Bearer ${stopToken}`;
+    fetch(`/api/v1/printers/${printerId}/camera/stop`, { method: 'POST', headers: stopHeaders }).catch(() => {});
 
 
     if (imgRef.current) imgRef.current.src = '';
     if (imgRef.current) imgRef.current.src = '';
     setTimeout(() => setImageKey(Date.now()), 100);
     setTimeout(() => setImageKey(Date.now()), 100);
@@ -482,6 +533,28 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
           <span className="truncate">{printer?.name || printerName}</span>
           <span className="truncate">{printer?.name || printerName}</span>
         </div>
         </div>
         <div className="flex items-center gap-1 no-drag">
         <div className="flex items-center gap-1 no-drag">
+          <button
+            onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
+            disabled={!status?.connected || chamberLightMutation.isPending || !hasPermission('printers:control')}
+            className={`p-1 rounded disabled:opacity-50 ${status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30' : 'hover:bg-bambu-dark-tertiary'}`}
+            title={!hasPermission('printers:control') ? t('printers.permission.noControl') : t('camera.chamberLight')}
+          >
+            <ChamberLight on={status?.chamber_light ?? false} className="w-3.5 h-3.5" />
+          </button>
+          <button
+            onClick={() => setShowSkipObjectsModal(true)}
+            disabled={!isPrintingWithObjects || !hasPermission('printers:control')}
+            className={`p-1 rounded disabled:opacity-50 ${isPrintingWithObjects && hasPermission('printers:control') ? 'hover:bg-bambu-dark-tertiary' : ''}`}
+            title={
+              !hasPermission('printers:control')
+                ? t('printers.permission.noControl')
+                : !isPrintingWithObjects
+                  ? t('printers.skipObjects.onlyWhilePrinting')
+                  : t('printers.skipObjects.tooltip')
+            }
+          >
+            <SkipObjectsIcon className="w-3.5 h-3.5 text-bambu-gray" />
+          </button>
           <button
           <button
             onClick={refresh}
             onClick={refresh}
             disabled={streamLoading || isReconnecting}
             disabled={streamLoading || isReconnecting}
@@ -625,6 +698,12 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
           )}
           )}
         </div>
         </div>
       )}
       )}
+      {/* Skip Objects Modal */}
+      <SkipObjectsModal
+        printerId={printerId}
+        isOpen={showSkipObjectsModal}
+        onClose={() => setShowSkipObjectsModal(false)}
+      />
     </div>
     </div>
   );
   );
 }
 }

+ 271 - 0
frontend/src/components/SkipObjectsModal.tsx

@@ -0,0 +1,271 @@
+import { useQuery, useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { X, Loader2, Monitor, AlertCircle, Box } from 'lucide-react';
+import { api } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
+
+// Custom Skip Objects icon - arrow jumping over boxes
+export const SkipObjectsIcon = ({ className }: { className?: string }) => (
+  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
+    {/* Three boxes at the bottom */}
+    <rect x="2" y="15" width="5" height="5" rx="0.5" />
+    <rect x="9.5" y="15" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.3" />
+    <rect x="17" y="15" width="5" height="5" rx="0.5" />
+    {/* Curved arrow jumping over first box */}
+    <path d="M4 12 C4 6, 14 6, 14 12" />
+    <polyline points="12,10 14,12 12,14" />
+  </svg>
+);
+
+interface SkipObjectsModalProps {
+  printerId: number;
+  isOpen: boolean;
+  onClose: () => void;
+}
+
+export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModalProps) {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const { hasPermission } = useAuth();
+
+  const { data: status } = useQuery({
+    queryKey: ['printerStatus', printerId],
+    queryFn: () => api.getPrinterStatus(printerId),
+    refetchInterval: 30000,
+    enabled: isOpen,
+  });
+
+  const { data: objectsData, refetch: refetchObjects } = useQuery({
+    queryKey: ['printableObjects', printerId],
+    queryFn: () => api.getPrintableObjects(printerId),
+    enabled: isOpen,
+    refetchInterval: isOpen ? 5000 : false,
+  });
+
+  const skipObjectsMutation = useMutation({
+    mutationFn: (objectIds: number[]) => api.skipObjects(printerId, objectIds),
+    onSuccess: (data) => {
+      showToast(data.message || t('printers.skipObjects.objectsSkipped'));
+      refetchObjects();
+    },
+    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSkipObjects'), 'error'),
+  });
+
+  if (!isOpen) return null;
+
+  return (
+    <div
+      className="fixed inset-0 z-50 flex items-center justify-center"
+      onClick={onClose}
+      onKeyDown={(e) => e.key === 'Escape' && onClose()}
+      tabIndex={-1}
+      ref={(el) => el?.focus()}
+    >
+      {/* Backdrop */}
+      <div className="absolute inset-0 bg-black/50 z-0" />
+      {/* Modal */}
+      <div
+        className="relative z-10 bg-white dark:bg-bambu-dark border border-gray-200 dark:border-bambu-dark-tertiary rounded-xl shadow-2xl w-[560px] max-h-[85vh] flex flex-col overflow-hidden"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark">
+          <div className="flex items-center gap-2">
+            <SkipObjectsIcon className="w-4 h-4 text-bambu-green" />
+            <span className="text-sm font-medium text-gray-900 dark:text-white">{t('printers.skipObjects.title')}</span>
+          </div>
+          <button
+            onClick={onClose}
+            className="p-1 text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white rounded transition-colors"
+          >
+            <X className="w-4 h-4" />
+          </button>
+        </div>
+
+        {!objectsData ? (
+          <div className="flex items-center justify-center py-12">
+            <Loader2 className="w-5 h-5 animate-spin text-bambu-gray" />
+          </div>
+        ) : objectsData.objects.length === 0 ? (
+          <div className="text-center py-8 px-4 text-bambu-gray">
+            <p className="text-sm">{t('printers.noObjectsFound')}</p>
+            <p className="text-xs mt-1 opacity-70">{t('printers.objectsLoadedOnPrintStart')}</p>
+          </div>
+        ) : (
+          <div className="flex flex-col overflow-hidden">
+            {/* Info Banner */}
+            <div className="flex items-center gap-3 px-4 py-2.5 bg-blue-50 dark:bg-blue-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
+              <div className="flex-shrink-0 w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center">
+                <Monitor className="w-4 h-4 text-blue-500 dark:text-blue-400" />
+              </div>
+              <div className="flex-1 min-w-0">
+                <p className="text-xs text-blue-600 dark:text-blue-300">{t('printers.skipObjects.matchIdsInfo')}</p>
+                <p className="text-[10px] text-blue-500/70 dark:text-blue-300/60">{t('printers.skipObjects.printerShowsIds')}</p>
+              </div>
+              <div className="flex-shrink-0 text-xs text-gray-500 dark:text-bambu-gray">
+                {objectsData.skipped_count}/{objectsData.total} {t('printers.skipObjects.skipped')}
+              </div>
+            </div>
+
+            {/* Layer Warning */}
+            {(status?.layer_num ?? 0) <= 1 && (
+              <div className="flex items-center gap-2 px-4 py-2 bg-amber-50 dark:bg-amber-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
+                <AlertCircle className="w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0" />
+                <p className="text-xs text-amber-600 dark:text-amber-400">
+                  {t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 })}
+                </p>
+              </div>
+            )}
+
+            {/* Content: Image + List side by side */}
+            <div className="flex flex-1 overflow-hidden">
+              {/* Left: Preview Image with object markers */}
+              <div className="w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary overflow-y-auto">
+                <div className="relative">
+                  {status?.cover_url ? (
+                    <img
+                      src={`${status.cover_url}?view=top`}
+                      alt={t('printers.printPreview')}
+                      className="w-full aspect-square object-contain rounded-lg bg-gray-900 dark:bg-gray-900 border border-gray-300 dark:border-gray-600"
+                    />
+                  ) : (
+                    <div className="w-full aspect-square rounded-lg bg-gray-100 dark:bg-bambu-dark flex items-center justify-center">
+                      <Box className="w-8 h-8 text-gray-300 dark:text-bambu-gray/30" />
+                    </div>
+                  )}
+                  {/* Object ID markers overlay - positioned based on object data */}
+                  {objectsData.objects.length > 0 && (
+                    <div className="absolute inset-0 pointer-events-none">
+                      {objectsData.objects.map((obj, idx) => {
+                        let x: number, y: number;
+
+                        // Use position data if available, otherwise fall back to grid
+                        if (obj.x != null && obj.y != null && objectsData.bbox_all) {
+                          // bbox_all defines the visible area in the top_N.png image
+                          // Format: [x_min, y_min, x_max, y_max] in mm
+                          const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
+                          const bboxWidth = xMax - xMin;
+                          const bboxHeight = yMax - yMin;
+
+                          // The image shows bbox_all area with some padding (~5-10%)
+                          const padding = 8;
+                          const contentArea = 100 - (padding * 2);
+
+                          // Map object position to image percentage
+                          x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
+                          // Y axis: image Y increases downward, but 3D Y increases toward back
+                          y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
+
+                          // Clamp to valid range
+                          x = Math.max(5, Math.min(95, x));
+                          y = Math.max(5, Math.min(95, y));
+                        } else if (obj.x != null && obj.y != null) {
+                          // Fallback: use full build plate (256mm)
+                          const buildPlate = 256;
+                          x = (obj.x / buildPlate) * 100;
+                          y = 100 - (obj.y / buildPlate) * 100;
+                          x = Math.max(5, Math.min(95, x));
+                          y = Math.max(5, Math.min(95, y));
+                        } else {
+                          // Fallback: arrange in a grid pattern over the build plate area
+                          const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
+                          const row = Math.floor(idx / cols);
+                          const col = idx % cols;
+                          const rows = Math.ceil(objectsData.objects.length / cols);
+                          x = 15 + (col * (70 / cols)) + (35 / cols);
+                          y = 15 + (row * (70 / rows)) + (35 / rows);
+                        }
+
+                        return (
+                          <div
+                            key={obj.id}
+                            className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
+                              obj.skipped
+                                ? 'bg-red-500 text-white line-through'
+                                : 'bg-bambu-green text-black'
+                            }`}
+                            style={{
+                              left: `${x}%`,
+                              top: `${y}%`,
+                              transform: 'translate(-50%, -50%)'
+                            }}
+                            title={obj.name}
+                          >
+                            {obj.id}
+                          </div>
+                        );
+                      })}
+                    </div>
+                  )}
+                  {/* Object count overlay */}
+                  <div className="absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm">
+                    {t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
+                  </div>
+                </div>
+              </div>
+
+              {/* Right: Object List with prominent IDs */}
+              <div className="flex-1 min-w-0 overflow-y-auto">
+                {objectsData.objects.map((obj) => (
+                  <div
+                    key={obj.id}
+                    className={`
+                      flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary/50 last:border-0
+                      ${obj.skipped ? 'bg-red-50 dark:bg-red-500/10' : 'hover:bg-gray-50 dark:hover:bg-bambu-dark/50'}
+                    `}
+                  >
+                    {/* Large prominent ID badge */}
+                    <div className={`
+                      w-12 h-12 flex-shrink-0 rounded-lg flex flex-col items-center justify-center
+                      ${obj.skipped
+                        ? 'bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/40'
+                        : 'bg-green-100 dark:bg-bambu-green/20 border border-green-300 dark:border-bambu-green/40'}
+                    `}>
+                      <span className={`text-lg font-mono font-bold ${obj.skipped ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-bambu-green'}`}>
+                        {obj.id}
+                      </span>
+                      <span className={`text-[8px] uppercase tracking-wider ${obj.skipped ? 'text-red-400/60' : 'text-green-500/60 dark:text-bambu-green/60'}`}>
+                        ID
+                      </span>
+                    </div>
+
+                    {/* Object name and status */}
+                    <div className="flex-1 min-w-0">
+                      <span className={`block text-sm truncate ${obj.skipped ? 'text-red-500 dark:text-red-400 line-through' : 'text-gray-900 dark:text-white'}`}>
+                        {obj.name}
+                      </span>
+                      {obj.skipped && (
+                        <span className="text-[10px] text-red-400/60">{t('printers.willBeSkipped')}</span>
+                      )}
+                    </div>
+
+                    {/* Skip button */}
+                    {!obj.skipped ? (
+                      <button
+                        onClick={() => skipObjectsMutation.mutate([obj.id])}
+                        disabled={skipObjectsMutation.isPending || (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')}
+                        className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${
+                          (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')
+                            ? 'bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed'
+                            : 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 border border-red-300 dark:border-red-500/30'
+                        }`}
+                        title={!hasPermission('printers:control') ? t('printers.permission.noControl') : ((status?.layer_num ?? 0) <= 1 ? t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 }) : t('printers.skipObjects.skip'))}
+                      >
+                        {t('printers.skipObjects.skip')}
+                      </button>
+                    ) : (
+                      <span className="px-4 py-2 text-xs text-red-500 dark:text-red-400/70 bg-red-100 dark:bg-red-500/10 rounded-lg">
+                        {t('printers.skipObjects.skipped')}
+                      </span>
+                    )}
+                  </div>
+                ))}
+              </div>
+            </div>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

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

@@ -1506,6 +1506,7 @@ export default {
     recording: 'Aufnahme',
     recording: 'Aufnahme',
     startRecording: 'Aufnahme starten',
     startRecording: 'Aufnahme starten',
     stopRecording: 'Aufnahme stoppen',
     stopRecording: 'Aufnahme stoppen',
+    chamberLight: 'Kammerbeleuchtung umschalten',
   },
   },
 
 
   // Groups management
   // Groups management

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

@@ -1506,6 +1506,7 @@ export default {
     recording: 'Recording',
     recording: 'Recording',
     startRecording: 'Start Recording',
     startRecording: 'Start Recording',
     stopRecording: 'Stop Recording',
     stopRecording: 'Stop Recording',
+    chamberLight: 'Toggle chamber light',
   },
   },
 
 
   // Groups management
   // Groups management

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

@@ -2638,6 +2638,7 @@ export default {
     skipForward: '5秒進む',
     skipForward: '5秒進む',
     refreshStream: 'ストリームを更新',
     refreshStream: 'ストリームを更新',
     dragToResize: 'ドラッグしてリサイズ',
     dragToResize: 'ドラッグしてリサイズ',
+    chamberLight: 'チャンバーライト切替',
   },
   },
 
 
   // アーカイブカードラベル
   // アーカイブカードラベル

+ 81 - 5
frontend/src/pages/CameraPage.tsx

@@ -1,9 +1,13 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useParams } from 'react-router-dom';
 import { useParams } from 'react-router-dom';
-import { useQuery } from '@tanstack/react-query';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
 import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
-import { api } from '../api/client';
+import { api, getAuthToken } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
+import { ChamberLight } from '../components/icons/ChamberLight';
+import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
 
 
 const MAX_RECONNECT_ATTEMPTS = 5;
 const MAX_RECONNECT_ATTEMPTS = 5;
 const INITIAL_RECONNECT_DELAY = 2000; // 2 seconds
 const INITIAL_RECONNECT_DELAY = 2000; // 2 seconds
@@ -12,10 +16,14 @@ const STALL_CHECK_INTERVAL = 5000; // Check every 5 seconds
 
 
 export function CameraPage() {
 export function CameraPage() {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const { printerId } = useParams<{ printerId: string }>();
   const { printerId } = useParams<{ printerId: string }>();
   const id = parseInt(printerId || '0', 10);
   const id = parseInt(printerId || '0', 10);
 
 
   const [streamMode, setStreamMode] = useState<'stream' | 'snapshot'>('stream');
   const [streamMode, setStreamMode] = useState<'stream' | 'snapshot'>('stream');
+  const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
   const [streamError, setStreamError] = useState(false);
   const [streamError, setStreamError] = useState(false);
   const [streamLoading, setStreamLoading] = useState(true);
   const [streamLoading, setStreamLoading] = useState(true);
   const [imageKey, setImageKey] = useState(Date.now());
   const [imageKey, setImageKey] = useState(Date.now());
@@ -43,6 +51,39 @@ export function CameraPage() {
     enabled: id > 0,
     enabled: id > 0,
   });
   });
 
 
+  // Fetch printer status for light toggle and skip objects
+  const { data: status } = useQuery({
+    queryKey: ['printerStatus', id],
+    queryFn: () => api.getPrinterStatus(id),
+    refetchInterval: 30000,
+    enabled: id > 0,
+  });
+
+  // Chamber light mutation with optimistic update
+  const chamberLightMutation = useMutation({
+    mutationFn: (on: boolean) => api.setChamberLight(id, on),
+    onMutate: async (on) => {
+      await queryClient.cancelQueries({ queryKey: ['printerStatus', id] });
+      const previousStatus = queryClient.getQueryData(['printerStatus', id]);
+      queryClient.setQueryData(['printerStatus', id], (old: typeof status) => ({
+        ...old,
+        chamber_light: on,
+      }));
+      return { previousStatus };
+    },
+    onSuccess: (_, on) => {
+      showToast(`Chamber light ${on ? 'on' : 'off'}`);
+    },
+    onError: (error: Error, _, context) => {
+      if (context?.previousStatus) {
+        queryClient.setQueryData(['printerStatus', id], context.previousStatus);
+      }
+      showToast(error.message || t('printers.toast.failedToControlChamberLight'), 'error');
+    },
+  });
+
+  const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE' || status?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2;
+
   // Update document title
   // Update document title
   useEffect(() => {
   useEffect(() => {
     if (printer) {
     if (printer) {
@@ -64,11 +105,14 @@ export function CameraPage() {
     const sendStopOnce = () => {
     const sendStopOnce = () => {
       if (id > 0 && !stopSentRef.current) {
       if (id > 0 && !stopSentRef.current) {
         stopSentRef.current = true;
         stopSentRef.current = true;
-        navigator.sendBeacon(stopUrl);
+        const headers: Record<string, string> = {};
+        const token = getAuthToken();
+        if (token) headers['Authorization'] = `Bearer ${token}`;
+        fetch(stopUrl, { method: 'POST', keepalive: true, headers }).catch(() => {});
       }
       }
     };
     };
 
 
-    // Handle page unload/close with sendBeacon (more reliable than fetch on unload)
+    // Handle page unload/close with keepalive fetch (more reliable than sendBeacon, supports auth)
     const handleBeforeUnload = () => {
     const handleBeforeUnload = () => {
       sendStopOnce();
       sendStopOnce();
     };
     };
@@ -303,7 +347,10 @@ export function CameraPage() {
 
 
   const stopStream = () => {
   const stopStream = () => {
     if (id > 0) {
     if (id > 0) {
-      fetch(`/api/v1/printers/${id}/camera/stop`).catch(() => {});
+      const headers: Record<string, string> = {};
+      const token = getAuthToken();
+      if (token) headers['Authorization'] = `Bearer ${token}`;
+      fetch(`/api/v1/printers/${id}/camera/stop`, { method: 'POST', headers }).catch(() => {});
     }
     }
   };
   };
 
 
@@ -577,6 +624,28 @@ export function CameraPage() {
               {t('camera.snapshot')}
               {t('camera.snapshot')}
             </button>
             </button>
           </div>
           </div>
+          <button
+            onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
+            disabled={!status?.connected || chamberLightMutation.isPending || !hasPermission('printers:control')}
+            className={`p-1.5 rounded disabled:opacity-50 ${status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30' : 'hover:bg-bambu-dark-tertiary'}`}
+            title={!hasPermission('printers:control') ? t('printers.permission.noControl') : t('camera.chamberLight')}
+          >
+            <ChamberLight on={status?.chamber_light ?? false} className="w-4 h-4" />
+          </button>
+          <button
+            onClick={() => setShowSkipObjectsModal(true)}
+            disabled={!isPrintingWithObjects || !hasPermission('printers:control')}
+            className={`p-1.5 rounded disabled:opacity-50 ${isPrintingWithObjects && hasPermission('printers:control') ? 'hover:bg-bambu-dark-tertiary' : ''}`}
+            title={
+              !hasPermission('printers:control')
+                ? t('printers.permission.noControl')
+                : !isPrintingWithObjects
+                  ? t('printers.skipObjects.onlyWhilePrinting')
+                  : t('printers.skipObjects.tooltip')
+            }
+          >
+            <SkipObjectsIcon className="w-4 h-4 text-bambu-gray" />
+          </button>
           <button
           <button
             onClick={refresh}
             onClick={refresh}
             disabled={isDisabled}
             disabled={isDisabled}
@@ -700,6 +769,13 @@ export function CameraPage() {
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
+
+      {/* Skip Objects Modal */}
+      <SkipObjectsModal
+        printerId={id}
+        isOpen={showSkipObjectsModal}
+        onClose={() => setShowSkipObjectsModal(false)}
+      />
     </div>
     </div>
   );
   );
 }
 }

+ 8 - 239
frontend/src/pages/PrintersPage.tsx

@@ -33,7 +33,6 @@ import {
   Pause,
   Pause,
   Play,
   Play,
   X,
   X,
-  Monitor,
   Fan,
   Fan,
   Wind,
   Wind,
   AirVent,
   AirVent,
@@ -45,18 +44,6 @@ import {
   Home,
   Home,
 } from 'lucide-react';
 } from 'lucide-react';
 
 
-// Custom Skip Objects icon - arrow jumping over boxes
-const SkipObjectsIcon = ({ className }: { className?: string }) => (
-  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
-    {/* Three boxes at the bottom */}
-    <rect x="2" y="15" width="5" height="5" rx="0.5" />
-    <rect x="9.5" y="15" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.3" />
-    <rect x="17" y="15" width="5" height="5" rx="0.5" />
-    {/* Curved arrow jumping over first box */}
-    <path d="M4 12 C4 6, 14 6, 14 12" />
-    <polyline points="12,10 14,12 12,14" />
-  </svg>
-);
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
 import { api, discoveryApi, firmwareApi } from '../api/client';
 import { api, discoveryApi, firmwareApi } from '../api/client';
 import { formatDateOnly } from '../utils/date';
 import { formatDateOnly } from '../utils/date';
@@ -75,6 +62,7 @@ import { LinkSpoolModal } from '../components/LinkSpoolModal';
 import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { ChamberLight } from '../components/icons/ChamberLight';
 import { ChamberLight } from '../components/icons/ChamberLight';
+import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
 
 
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
@@ -1265,23 +1253,13 @@ function PrinterCard({
   // Query for printable objects (for skip functionality)
   // Query for printable objects (for skip functionality)
   // Fetch when printing with 2+ objects OR when modal is open
   // Fetch when printing with 2+ objects OR when modal is open
   const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE' || status?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2;
   const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE' || status?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2;
-  const { data: objectsData, refetch: refetchObjects } = useQuery({
+  const { data: objectsData } = useQuery({
     queryKey: ['printableObjects', printer.id],
     queryKey: ['printableObjects', printer.id],
     queryFn: () => api.getPrintableObjects(printer.id),
     queryFn: () => api.getPrintableObjects(printer.id),
     enabled: showSkipObjectsModal || isPrintingWithObjects,
     enabled: showSkipObjectsModal || isPrintingWithObjects,
     refetchInterval: showSkipObjectsModal ? 5000 : (isPrintingWithObjects ? 30000 : false), // 5s when modal open, 30s otherwise
     refetchInterval: showSkipObjectsModal ? 5000 : (isPrintingWithObjects ? 30000 : false), // 5s when modal open, 30s otherwise
   });
   });
 
 
-  // Skip objects mutation
-  const skipObjectsMutation = useMutation({
-    mutationFn: (objectIds: number[]) => api.skipObjects(printer.id, objectIds),
-    onSuccess: (data) => {
-      showToast(data.message || t('printers.skipObjects.objectsSkipped'));
-      refetchObjects();
-    },
-    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSkipObjects'), 'error'),
-  });
-
   // State for tracking which AMS slot is being refreshed
   // State for tracking which AMS slot is being refreshed
   const [refreshingSlot, setRefreshingSlot] = useState<{ amsId: number; slotId: number } | null>(null);
   const [refreshingSlot, setRefreshingSlot] = useState<{ amsId: number; slotId: number } | null>(null);
   // Track if we've seen the printer enter "busy" state (ams_status_main !== 0)
   // Track if we've seen the printer enter "busy" state (ams_status_main !== 0)
@@ -3250,221 +3228,12 @@ function PrinterCard({
         />
         />
       )}
       )}
 
 
-      {/* Skip Objects Popup */}
-      {showSkipObjectsModal && (
-        <div
-          className="fixed inset-0 z-50 flex items-center justify-center"
-          onClick={() => setShowSkipObjectsModal(false)}
-          onKeyDown={(e) => e.key === 'Escape' && setShowSkipObjectsModal(false)}
-          tabIndex={-1}
-          ref={(el) => el?.focus()}
-        >
-          {/* Backdrop */}
-          <div className="absolute inset-0 bg-black/50 z-0" />
-          {/* Modal */}
-          <div
-            className="relative z-10 bg-white dark:bg-bambu-dark border border-gray-200 dark:border-bambu-dark-tertiary rounded-xl shadow-2xl w-[560px] max-h-[85vh] flex flex-col overflow-hidden"
-            onClick={(e) => e.stopPropagation()}
-          >
-          {/* Header */}
-          <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark">
-            <div className="flex items-center gap-2">
-              <SkipObjectsIcon className="w-4 h-4 text-bambu-green" />
-              <span className="text-sm font-medium text-gray-900 dark:text-white">{t('printers.skipObjects.title')}</span>
-            </div>
-            <button
-              onClick={() => setShowSkipObjectsModal(false)}
-              className="p-1 text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white rounded transition-colors"
-            >
-              <X className="w-4 h-4" />
-            </button>
-          </div>
-
-          {!objectsData ? (
-            <div className="flex items-center justify-center py-12">
-              <Loader2 className="w-5 h-5 animate-spin text-bambu-gray" />
-            </div>
-          ) : objectsData.objects.length === 0 ? (
-            <div className="text-center py-8 px-4 text-bambu-gray">
-              <p className="text-sm">{t('printers.noObjectsFound')}</p>
-              <p className="text-xs mt-1 opacity-70">{t('printers.objectsLoadedOnPrintStart')}</p>
-            </div>
-          ) : (
-            <div className="flex flex-col overflow-hidden">
-              {/* Info Banner */}
-              <div className="flex items-center gap-3 px-4 py-2.5 bg-blue-50 dark:bg-blue-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
-                <div className="flex-shrink-0 w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center">
-                  <Monitor className="w-4 h-4 text-blue-500 dark:text-blue-400" />
-                </div>
-                <div className="flex-1 min-w-0">
-                  <p className="text-xs text-blue-600 dark:text-blue-300">{t('printers.skipObjects.matchIdsInfo')}</p>
-                  <p className="text-[10px] text-blue-500/70 dark:text-blue-300/60">{t('printers.skipObjects.printerShowsIds')}</p>
-                </div>
-                <div className="flex-shrink-0 text-xs text-gray-500 dark:text-bambu-gray">
-                  {objectsData.skipped_count}/{objectsData.total} {t('printers.skipObjects.skipped')}
-                </div>
-              </div>
-
-              {/* Layer Warning */}
-              {(status?.layer_num ?? 0) <= 1 && (
-                <div className="flex items-center gap-2 px-4 py-2 bg-amber-50 dark:bg-amber-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
-                  <AlertCircle className="w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0" />
-                  <p className="text-xs text-amber-600 dark:text-amber-400">
-                    {t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 })}
-                  </p>
-                </div>
-              )}
-
-              {/* Content: Image + List side by side */}
-              <div className="flex flex-1 overflow-hidden">
-                {/* Left: Preview Image with object markers */}
-                <div className="w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary overflow-y-auto">
-                  <div className="relative">
-                    {status?.cover_url ? (
-                      <img
-                        src={`${status.cover_url}?view=top`}
-                        alt={t('printers.printPreview')}
-                        className="w-full aspect-square object-contain rounded-lg bg-gray-900 dark:bg-gray-900 border border-gray-300 dark:border-gray-600"
-                      />
-                    ) : (
-                      <div className="w-full aspect-square rounded-lg bg-gray-100 dark:bg-bambu-dark flex items-center justify-center">
-                        <Box className="w-8 h-8 text-gray-300 dark:text-bambu-gray/30" />
-                      </div>
-                    )}
-                    {/* Object ID markers overlay - positioned based on object data */}
-                    {objectsData.objects.length > 0 && (
-                      <div className="absolute inset-0 pointer-events-none">
-                        {objectsData.objects.map((obj, idx) => {
-                          let x: number, y: number;
-
-                          // Use position data if available, otherwise fall back to grid
-                          if (obj.x != null && obj.y != null && objectsData.bbox_all) {
-                            // bbox_all defines the visible area in the top_N.png image
-                            // Format: [x_min, y_min, x_max, y_max] in mm
-                            const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
-                            const bboxWidth = xMax - xMin;
-                            const bboxHeight = yMax - yMin;
-
-                            // The image shows bbox_all area with some padding (~5-10%)
-                            const padding = 8;
-                            const contentArea = 100 - (padding * 2);
-
-                            // Map object position to image percentage
-                            x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
-                            // Y axis: image Y increases downward, but 3D Y increases toward back
-                            y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
-
-                            // Clamp to valid range
-                            x = Math.max(5, Math.min(95, x));
-                            y = Math.max(5, Math.min(95, y));
-                          } else if (obj.x != null && obj.y != null) {
-                            // Fallback: use full build plate (256mm)
-                            const buildPlate = 256;
-                            x = (obj.x / buildPlate) * 100;
-                            y = 100 - (obj.y / buildPlate) * 100;
-                            x = Math.max(5, Math.min(95, x));
-                            y = Math.max(5, Math.min(95, y));
-                          } else {
-                            // Fallback: arrange in a grid pattern over the build plate area
-                            const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
-                            const row = Math.floor(idx / cols);
-                            const col = idx % cols;
-                            const rows = Math.ceil(objectsData.objects.length / cols);
-                            x = 15 + (col * (70 / cols)) + (35 / cols);
-                            y = 15 + (row * (70 / rows)) + (35 / rows);
-                          }
-
-                          return (
-                            <div
-                              key={obj.id}
-                              className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
-                                obj.skipped
-                                  ? 'bg-red-500 text-white line-through'
-                                  : 'bg-bambu-green text-black'
-                              }`}
-                              style={{
-                                left: `${x}%`,
-                                top: `${y}%`,
-                                transform: 'translate(-50%, -50%)'
-                              }}
-                              title={obj.name}
-                            >
-                              {obj.id}
-                            </div>
-                          );
-                        })}
-                      </div>
-                    )}
-                    {/* Object count overlay */}
-                    <div className="absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm">
-                      {t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
-                    </div>
-                  </div>
-                </div>
-
-                {/* Right: Object List with prominent IDs */}
-                <div className="flex-1 min-w-0 overflow-y-auto">
-                  {objectsData.objects.map((obj) => (
-                    <div
-                      key={obj.id}
-                      className={`
-                        flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary/50 last:border-0
-                        ${obj.skipped ? 'bg-red-50 dark:bg-red-500/10' : 'hover:bg-gray-50 dark:hover:bg-bambu-dark/50'}
-                      `}
-                    >
-                      {/* Large prominent ID badge */}
-                      <div className={`
-                        w-12 h-12 flex-shrink-0 rounded-lg flex flex-col items-center justify-center
-                        ${obj.skipped
-                          ? 'bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/40'
-                          : 'bg-green-100 dark:bg-bambu-green/20 border border-green-300 dark:border-bambu-green/40'}
-                      `}>
-                        <span className={`text-lg font-mono font-bold ${obj.skipped ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-bambu-green'}`}>
-                          {obj.id}
-                        </span>
-                        <span className={`text-[8px] uppercase tracking-wider ${obj.skipped ? 'text-red-400/60' : 'text-green-500/60 dark:text-bambu-green/60'}`}>
-                          ID
-                        </span>
-                      </div>
-
-                      {/* Object name and status */}
-                      <div className="flex-1 min-w-0">
-                        <span className={`block text-sm truncate ${obj.skipped ? 'text-red-500 dark:text-red-400 line-through' : 'text-gray-900 dark:text-white'}`}>
-                          {obj.name}
-                        </span>
-                        {obj.skipped && (
-                          <span className="text-[10px] text-red-400/60">{t('printers.willBeSkipped')}</span>
-                        )}
-                      </div>
-
-                      {/* Skip button */}
-                      {!obj.skipped ? (
-                        <button
-                          onClick={() => skipObjectsMutation.mutate([obj.id])}
-                          disabled={skipObjectsMutation.isPending || (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')}
-                          className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${
-                            (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')
-                              ? 'bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed'
-                              : 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 border border-red-300 dark:border-red-500/30'
-                          }`}
-                          title={!hasPermission('printers:control') ? t('printers.permission.noControl') : ((status?.layer_num ?? 0) <= 1 ? t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 }) : t('printers.skipObjects.skip'))}
-                        >
-                          {t('printers.skipObjects.skip')}
-                        </button>
-                      ) : (
-                        <span className="px-4 py-2 text-xs text-red-500 dark:text-red-400/70 bg-red-100 dark:bg-red-500/10 rounded-lg">
-                          {t('printers.skipObjects.skipped')}
-                        </span>
-                      )}
-                    </div>
-                  ))}
-                </div>
-              </div>
-            </div>
-          )}
-          </div>
-        </div>
-      )}
+      {/* Skip Objects Modal */}
+      <SkipObjectsModal
+        printerId={printer.id}
+        isOpen={showSkipObjectsModal}
+        onClose={() => setShowSkipObjectsModal(false)}
+      />
 
 
       {/* HMS Error Modal */}
       {/* HMS Error Modal */}
       {showHMSModal && (
       {showHMSModal && (

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-ueO9Fu53.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-BZQD54OI.js"></script>
+    <script type="module" crossorigin src="/assets/index-ueO9Fu53.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-togsBDt6.css">
     <link rel="stylesheet" crossorigin href="/assets/index-togsBDt6.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов