Преглед на файлове

Cam Wall tiles show temperatures and finish time, add controls (#3479)

- Full overlay: nozzle (both on dual-nozzle), bed and chamber temps on
  every connected tile; finish time beside time left while printing
- Pause/resume and stop (with the card's confirmations) and the chamber
  light on hover, always shown on touch screens
- Controls need printers:control; none for offline printers or kiosk
  (token) walls; rendered beside the tile, which is itself a button
- New printers.camWall.eta key, overlay hint updated in all locales
- Tile controls setting (on hover / always) in the wall settings,
  stored per browser
maziggy преди 4 дни
родител
ревизия
3318469ba9
променени са 84 файла, в които са добавени 814 реда и са изтрити 25 реда
  1. 1 0
      CHANGELOG.md
  2. 311 0
      frontend/src/__tests__/components/cameraWallCardInfo3479.test.tsx
  3. 29 0
      frontend/src/__tests__/pages/PrintersPageCamWallTileSize.test.tsx
  4. 49 2
      frontend/src/components/CameraTile.tsx
  5. 173 0
      frontend/src/components/CameraTileControls.tsx
  6. 77 0
      frontend/src/components/CameraWall.tsx
  7. 14 0
      frontend/src/components/camWallControls.ts
  8. 8 1
      frontend/src/i18n/locales/de.ts
  9. 8 1
      frontend/src/i18n/locales/en.ts
  10. 8 1
      frontend/src/i18n/locales/es.ts
  11. 8 1
      frontend/src/i18n/locales/fr.ts
  12. 8 1
      frontend/src/i18n/locales/it.ts
  13. 8 1
      frontend/src/i18n/locales/ja.ts
  14. 8 1
      frontend/src/i18n/locales/ko.ts
  15. 8 1
      frontend/src/i18n/locales/nl.ts
  16. 8 1
      frontend/src/i18n/locales/pl.ts
  17. 8 1
      frontend/src/i18n/locales/pt-BR.ts
  18. 8 1
      frontend/src/i18n/locales/ru.ts
  19. 8 1
      frontend/src/i18n/locales/sv.ts
  20. 8 1
      frontend/src/i18n/locales/tr.ts
  21. 8 1
      frontend/src/i18n/locales/uk.ts
  22. 8 1
      frontend/src/i18n/locales/zh-CN.ts
  23. 8 1
      frontend/src/i18n/locales/zh-TW.ts
  24. 17 0
      frontend/src/pages/CamWallPage.tsx
  25. 9 0
      frontend/src/pages/PrintersPage.tsx
  26. 0 0
      static/assets/ArchivesPage-JFcyEgzs.js
  27. 0 0
      static/assets/CamWallPage-BzrTfqQP.js
  28. 0 0
      static/assets/CamWallPage-UV48uNNm.js
  29. 0 0
      static/assets/CameraPage-DYCavmhk.js
  30. 0 0
      static/assets/CameraWall-D46f3IC6.js
  31. 0 0
      static/assets/EmbeddedCameraViewer-CAusapsF.js
  32. 0 0
      static/assets/FileManagerPage-D4tYnefa.js
  33. 0 0
      static/assets/InventoryPage-CeOCbp8N.js
  34. 0 0
      static/assets/Layout-DQdP8ELS.js
  35. 0 0
      static/assets/Layout-DaLZ1Hi3.js
  36. 0 0
      static/assets/ModelSourcesPage-Dxh4JFUk.js
  37. 0 0
      static/assets/PrintersPage-A4Lovfbi.js
  38. 0 0
      static/assets/PrintersPage-BrT8aKnl.js
  39. 0 0
      static/assets/SettingsPage-CHx4Lvii.js
  40. 0 0
      static/assets/SkipObjectsModal-BcQGxu6h.js
  41. 0 0
      static/assets/SkipObjectsModal-DhyycWv0.js
  42. 0 0
      static/assets/SliceModal-reQHg7hG.js
  43. 0 0
      static/assets/SpoolBuddyInventoryPage-4sjIwYij.js
  44. 1 1
      static/assets/SpoolBuddySettingsPage-DatFcZUl.js
  45. 0 0
      static/assets/camWallControls-Gm6zLwmD.js
  46. 0 0
      static/assets/de-B-_RnsT_.js
  47. 0 0
      static/assets/de-D-Ik6cQi.js
  48. 0 0
      static/assets/es-B7Qrxs22.js
  49. 0 0
      static/assets/es-PKf4Cad_.js
  50. 0 0
      static/assets/fr-B7wZdUbJ.js
  51. 0 0
      static/assets/fr-nDqI16Sc.js
  52. 0 0
      static/assets/index-CPjehcO9.js
  53. 0 0
      static/assets/index-vGjPx3LJ.css
  54. 0 0
      static/assets/it-B3t9J8b4.js
  55. 0 0
      static/assets/it-DZkkk939.js
  56. 0 0
      static/assets/ja-CH8ApWLn.js
  57. 0 0
      static/assets/ja-DGyQ2YrU.js
  58. 0 0
      static/assets/ko-Bi0wUs5y.js
  59. 0 0
      static/assets/ko-s7c341YZ.js
  60. 0 0
      static/assets/locationSensorDefaults-Ba0BKrgT.js
  61. 0 0
      static/assets/nl-C5HKndvZ.js
  62. 0 0
      static/assets/nl-DtlISEPr.js
  63. 0 0
      static/assets/pl-BNF_Q2Qv.js
  64. 0 0
      static/assets/pl-BSF5cGtV.js
  65. 0 1
      static/assets/plateCheck-DOHThmIE.js
  66. 0 0
      static/assets/pt-BR-BGrtweOA.js
  67. 0 0
      static/assets/pt-BR-JuKchEHn.js
  68. 0 0
      static/assets/ru-BijYtPGG.js
  69. 0 0
      static/assets/ru-By63XKsD.js
  70. 0 0
      static/assets/spoolCost-BSkLxT27.js
  71. 0 0
      static/assets/sv-BeYXjkxq.js
  72. 0 0
      static/assets/sv-DE21FGoi.js
  73. 0 0
      static/assets/tr-BOyUwTuE.js
  74. 0 0
      static/assets/tr-CDRrfHTL.js
  75. 0 0
      static/assets/uk-C5MfpycI.js
  76. 0 0
      static/assets/uk-D6-hB6zN.js
  77. 0 1
      static/assets/useCameraViewerId-BG_jtrZ0.js
  78. 1 0
      static/assets/useCameraViewerId-BVlJicPn.js
  79. 0 0
      static/assets/usePageFileDrop-DN2gT4m3.js
  80. 0 0
      static/assets/zh-CN-CUkTFdpC.js
  81. 0 0
      static/assets/zh-CN-DQh4mrY6.js
  82. 0 0
      static/assets/zh-TW-DxiHu4YR.js
  83. 0 0
      static/assets/zh-TW-wjTdiZua.js
  84. 4 4
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.3.1b1] - Unreleased
 
 ### Added
+- **Cam Wall tiles show temperatures and finish time, and can pause, stop and switch the light (#3479, sponsor vote Q4 2026)** — With the **Full** status overlay, every connected tile shows its nozzle (both on a dual-nozzle printer), bed and chamber temperatures, and a printing tile adds its finish time beside the time left. Hovering over a tile shows **Pause/Resume** and **Stop**, with the same confirmations as the printer card, and a **chamber light** switch; **Tile controls: Always** in the wall settings keeps them on screen, and a touch screen always shows them. The controls need the `printers:control` permission; a user without it, an offline printer and a Cam Wall token (kiosk) wall get none.
 
 ### Changed
 

+ 311 - 0
frontend/src/__tests__/components/cameraWallCardInfo3479.test.tsx

@@ -0,0 +1,311 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { render as rtlRender } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter } from 'react-router-dom';
+import { render } from '../utils';
+import { ToastProvider } from '../../contexts/ToastContext';
+import { api } from '../../api/client';
+import { CamWallPage } from '../../pages/CamWallPage';
+import { CameraTile } from '../../components/CameraTile';
+import { CameraWall, type CameraWallStatus } from '../../components/CameraWall';
+
+// The camera wall tiles carry what a printer card shows at a glance: the
+// heaters and the finish time in the full overlay, and pause/resume, stop and
+// the chamber light for a user allowed to control printers (#3479).
+
+let canControl = true;
+let signedInUser: { id: number; username: string } | null = null;
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  return {
+    ...actual,
+    useAuth: () => ({
+      hasPermission: (p: string) => (p === 'printers:control' ? canControl : true),
+      hasAnyPermission: () => true,
+      authEnabled: true,
+      user: signedInUser,
+      loading: false,
+    }),
+  };
+});
+
+const PRINTER = { id: 5, name: 'Farm-5' };
+const PRINTING: CameraWallStatus = {
+  connected: true,
+  state: 'RUNNING',
+  progress: 40,
+  remaining_time: 90,
+  temperatures: { nozzle: 219.6, bed: 60, chamber: 34.4 },
+  chamber_light: false,
+};
+
+let posted: string[];
+
+beforeEach(() => {
+  canControl = true;
+  signedInUser = null;
+  posted = [];
+  vi.spyOn(global, 'fetch');
+  server.use(
+    http.post('/api/v1/printers/:id/print/:action', ({ request, params }) => {
+      posted.push(`${params.action} ${new URL(request.url).pathname}`);
+      return HttpResponse.json({ success: true, message: 'ok' });
+    }),
+    http.post('/api/v1/printers/:id/chamber-light', ({ request }) => {
+      posted.push(`light ${new URL(request.url).searchParams.get('on')}`);
+      return HttpResponse.json({ success: true, message: 'ok' });
+    }),
+    http.post('/api/v1/printers/:id/camera/stop', () => HttpResponse.json({ success: true })),
+  );
+});
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+function renderWall(
+  status: CameraWallStatus,
+  props: Partial<Parameters<typeof CameraWall>[0]> = { showControls: true },
+) {
+  return render(
+    <CameraWall
+      printers={[PRINTER]}
+      maxLive={0}
+      snapshotIntervalSec={8}
+      statusMode="full"
+      statuses={new Map([[PRINTER.id, status]])}
+      onChangeMaxLive={() => {}}
+      onChangeSnapshotIntervalSec={() => {}}
+      onChangeStatusMode={() => {}}
+      onTileClick={() => {}}
+      timeFormat="24h"
+      {...props}
+    />,
+  );
+}
+
+describe('tile information', () => {
+  function tile(props: Partial<Parameters<typeof CameraTile>[0]>) {
+    return render(
+      <CameraTile
+        printerId={5}
+        printerName="Farm-5"
+        mode="paused"
+        snapshotIntervalMs={8000}
+        connected
+        statusMode="full"
+        printerState="RUNNING"
+        remainingMin={90}
+        temperatures={{ nozzle: 219.6, nozzle_2: 180, bed: 60, chamber: 34.4 }}
+        timeFormat="24h"
+        {...props}
+      />,
+    );
+  }
+
+  it('shows every heater, both nozzles of a dual-nozzle printer, rounded', () => {
+    tile({});
+    expect(screen.getByTitle('Nozzle')).toHaveTextContent('220° / 180°');
+    expect(screen.getByTitle('Bed')).toHaveTextContent('60°');
+    expect(screen.getByTitle('Chamber')).toHaveTextContent('34°');
+  });
+
+  it('leaves out a heater the printer does not report', () => {
+    tile({ temperatures: { nozzle: 200, bed: 55 } });
+    expect(screen.queryByTitle('Chamber')).not.toBeInTheDocument();
+  });
+
+  it('shows the heaters on an idle printer too', () => {
+    tile({ printerState: 'IDLE', remainingMin: null });
+    expect(screen.getByTitle('Bed')).toHaveTextContent('60°');
+  });
+
+  it('shows the finish time while printing', () => {
+    tile({});
+    expect(screen.getByText(/^ETA \d{2}:\d{2}$|^ETA Tomorrow \d{2}:\d{2}$/)).toBeInTheDocument();
+  });
+
+  it('shows no finish time for a paused print, whose end moves with the pause', () => {
+    tile({ printerState: 'PAUSE' });
+    expect(screen.getByText(/left$/)).toBeInTheDocument();
+    expect(screen.queryByText(/^ETA/)).not.toBeInTheDocument();
+  });
+
+  it('keeps the compact overlay to the state badge', () => {
+    tile({ statusMode: 'compact' });
+    expect(screen.queryByTitle('Bed')).not.toBeInTheDocument();
+    expect(screen.queryByText(/^ETA/)).not.toBeInTheDocument();
+  });
+
+  it('shows no heaters for an offline printer', () => {
+    tile({ connected: false });
+    expect(screen.queryByTitle('Bed')).not.toBeInTheDocument();
+  });
+});
+
+describe('tile controls', () => {
+  it('pauses a print after the same confirmation as the card', async () => {
+    const user = userEvent.setup();
+    renderWall(PRINTING);
+
+    await user.click(await screen.findByRole('button', { name: 'Pause' }));
+    expect(posted).toEqual([]);
+    await screen.findByText(/pause the current print on "Farm-5"/);
+    await user.click(screen.getByRole('button', { name: 'Pause Print' }));
+
+    await waitFor(() => expect(posted).toEqual(['pause /api/v1/printers/5/print/pause']));
+  });
+
+  it('resumes a paused print', async () => {
+    const user = userEvent.setup();
+    renderWall({ ...PRINTING, state: 'PAUSE' });
+
+    await user.click(await screen.findByRole('button', { name: 'Resume' }));
+    await screen.findByText(/resume the print on "Farm-5"/);
+    await user.click(screen.getByRole('button', { name: 'Resume Print' }));
+
+    await waitFor(() => expect(posted).toEqual(['resume /api/v1/printers/5/print/resume']));
+  });
+
+  it('stops a print only after it is confirmed', async () => {
+    const user = userEvent.setup();
+    renderWall(PRINTING);
+
+    await user.click(await screen.findByRole('button', { name: 'Stop' }));
+    await screen.findByText(/stop the current print on "Farm-5"/);
+    await user.click(screen.getByRole('button', { name: 'Cancel' }));
+    expect(posted).toEqual([]);
+
+    await user.click(screen.getByRole('button', { name: 'Stop' }));
+    await screen.findByText(/stop the current print on "Farm-5"/);
+    await user.click(screen.getByRole('button', { name: 'Stop Print' }));
+    await waitFor(() => expect(posted).toEqual(['stop /api/v1/printers/5/print/stop']));
+  });
+
+  it('switches the chamber light', async () => {
+    const user = userEvent.setup();
+    renderWall(PRINTING);
+
+    await user.click(await screen.findByRole('button', { name: 'Turn on chamber light' }));
+    await waitFor(() => expect(posted).toEqual(['light true']));
+  });
+
+  it('offers only the light on a printer that is not printing', async () => {
+    renderWall({ ...PRINTING, state: 'IDLE' });
+
+    expect(await screen.findByRole('button', { name: 'Turn on chamber light' })).toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'Stop' })).not.toBeInTheDocument();
+  });
+
+  it('gives a user without printers:control no controls', async () => {
+    canControl = false;
+    renderWall(PRINTING);
+
+    // The tile itself is there; only its controls are not.
+    expect(await screen.findByTitle('Bed')).toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'Stop' })).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: /chamber light/ })).not.toBeInTheDocument();
+  });
+
+  it('gives an offline printer no controls', async () => {
+    renderWall({ ...PRINTING, connected: false });
+
+    expect(screen.queryByRole('button', { name: /chamber light/ })).not.toBeInTheDocument();
+  });
+
+  it('gives a wall without controls (the kiosk) none', async () => {
+    renderWall(PRINTING, { showControls: false });
+
+    expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: /chamber light/ })).not.toBeInTheDocument();
+  });
+});
+
+describe('kiosk wall', () => {
+  it('has no controls, also where the browser could control printers', async () => {
+    vi.spyOn(api, 'getCamWallPrinters').mockResolvedValue([
+      { id: 7, name: 'X1C-Lab', camera_rotation: 0, connected: true, state: 'RUNNING', progress: 42, remaining_time: 33, layer_num: 1, total_layers: 2, hms_errors: [] },
+    ]);
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+    rtlRender(
+      <QueryClientProvider client={queryClient}>
+        <MemoryRouter initialEntries={['/camwall?token=bblt_abcdefgh_secretsecretsecret']}>
+          <ToastProvider>
+            <CamWallPage />
+          </ToastProvider>
+        </MemoryRouter>
+      </QueryClientProvider>,
+    );
+
+    expect(await screen.findByText('X1C-Lab')).toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: /chamber light/ })).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument();
+  });
+});
+
+describe('when the tile controls show', () => {
+  const controls = () => screen.getByTestId('camwall-tile-controls');
+
+  it('waits for hover by default', async () => {
+    renderWall(PRINTING);
+    await screen.findByRole('button', { name: 'Pause' });
+    expect(controls().className).toContain('opacity-0');
+    expect(controls().className).toContain('group-hover/tile:opacity-100');
+    // No hover on a touch screen: shown there all the same.
+    expect(controls().className).toContain('[@media(hover:none)]:opacity-100');
+  });
+
+  it('shows them all the time when set to always', async () => {
+    renderWall(PRINTING, { showControls: true, controlsMode: 'always', onChangeControlsMode: () => {} });
+    await screen.findByRole('button', { name: 'Pause' });
+    expect(controls().className).not.toContain('opacity-0');
+  });
+
+  it('is chosen in the wall settings', async () => {
+    const onChange = vi.fn();
+    const user = userEvent.setup();
+    renderWall(PRINTING, { showControls: true, controlsMode: 'hover', onChangeControlsMode: onChange });
+
+    await user.click(screen.getByRole('button', { name: 'Cam wall settings' }));
+    const group = screen.getByRole('radiogroup', { name: 'Tile controls' });
+    expect(within(group).getByRole('radio', { name: 'On hover' })).toHaveAttribute('aria-checked', 'true');
+    await user.click(within(group).getByRole('radio', { name: 'Always' }));
+    expect(onChange).toHaveBeenCalledWith('always');
+  });
+
+  it('is not offered on a wall without controls', async () => {
+    const user = userEvent.setup();
+    renderWall(PRINTING, { showControls: false, onChangeControlsMode: () => {} });
+
+    await user.click(screen.getByRole('button', { name: 'Cam wall settings' }));
+    expect(screen.queryByRole('radiogroup', { name: 'Tile controls' })).not.toBeInTheDocument();
+  });
+});
+
+describe('the /camwall page, signed in', () => {
+  it('follows the choice saved in this browser', async () => {
+    signedInUser = { id: 1, username: 'farm' };
+    vi.mocked(localStorage.getItem).mockImplementation((key: string) => (key === 'camWallControls' ? 'always' : null));
+    vi.spyOn(api, 'getPrinters').mockResolvedValue([{ id: 7, name: 'X1C-Lab', camera_rotation: 0 }] as never);
+    vi.spyOn(api, 'getPrinterStatus').mockResolvedValue({ ...PRINTING, hms_errors: [] } as never);
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+    rtlRender(
+      <QueryClientProvider client={queryClient}>
+        <MemoryRouter initialEntries={['/camwall']}>
+          <ToastProvider>
+            <CamWallPage />
+          </ToastProvider>
+        </MemoryRouter>
+      </QueryClientProvider>,
+    );
+
+    await screen.findByRole('button', { name: 'Pause' });
+    expect(screen.getByTestId('camwall-tile-controls').className).not.toContain('opacity-0');
+  });
+});

+ 29 - 0
frontend/src/__tests__/pages/PrintersPageCamWallTileSize.test.tsx

@@ -115,3 +115,32 @@ describe('Cam Wall tile size', () => {
     expect(screen.queryByTitle('Large tiles')).not.toBeInTheDocument();
   });
 });
+
+describe('Cam Wall tile controls (#3479)', () => {
+  it('gives the Printers page wall its tile controls', async () => {
+    store.set('printerPageView', 'camwall');
+    render(<PrintersPage />);
+
+    expect(await screen.findByRole('button', { name: 'Turn on chamber light' })).toBeInTheDocument();
+  });
+
+  it('keeps the choice to always show them in this browser', async () => {
+    store.set('printerPageView', 'camwall');
+    const user = userEvent.setup();
+    const view = render(<PrintersPage />);
+
+    await screen.findByRole('button', { name: 'Turn on chamber light' });
+    expect(screen.getByTestId('camwall-tile-controls').className).toContain('opacity-0');
+
+    await user.click(screen.getByRole('button', { name: 'Cam wall settings' }));
+    await user.click(screen.getByRole('radio', { name: 'Always' }));
+    expect(store.get('camWallControls')).toBe('always');
+    expect(screen.getByTestId('camwall-tile-controls').className).not.toContain('opacity-0');
+
+    // A later visit starts from the saved choice.
+    view.unmount();
+    render(<PrintersPage />);
+    await screen.findByRole('button', { name: 'Turn on chamber light' });
+    expect(screen.getByTestId('camwall-tile-controls').className).not.toContain('opacity-0');
+  });
+});

+ 49 - 2
frontend/src/components/CameraTile.tsx

@@ -1,8 +1,8 @@
 import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
 import { useTranslation } from 'react-i18next';
-import { AlertTriangle, VideoOff, WifiOff } from 'lucide-react';
+import { AlertTriangle, Box, Flame, RectangleHorizontal, VideoOff, WifiOff } from 'lucide-react';
 import { getAuthToken, withStreamToken } from '../api/client';
-import { formatDuration } from '../utils/date';
+import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
 import { useStreamImgRef } from '../hooks/useStreamImgRef';
 import { useCameraViewerId } from '../hooks/useCameraViewerId';
 import { isSideways, useSidewaysBox } from '../hooks/useSidewaysBox';
@@ -10,6 +10,14 @@ import { isSideways, useSidewaysBox } from '../hooks/useSidewaysBox';
 export type CameraTileMode = 'live' | 'snapshot' | 'paused';
 export type CameraTileStatusMode = 'off' | 'compact' | 'full';
 
+/** The heater readings a tile shows in the full overlay (#3479). */
+export interface CameraTileTemperatures {
+  nozzle?: number;
+  nozzle_2?: number;
+  bed?: number;
+  chamber?: number;
+}
+
 interface CameraTileProps {
   printerId: number;
   printerName: string;
@@ -28,6 +36,10 @@ interface CameraTileProps {
   totalLayers?: number | null;
   printName?: string | null;
   hmsErrorCount?: number;
+  // Full overlay only (#3479): the heaters, on every connected tile, and the
+  // finish time beside the time left while printing.
+  temperatures?: CameraTileTemperatures | null;
+  timeFormat?: TimeFormat;
 }
 
 // Tiles render lighter than EmbeddedCameraViewer's full window: lower fps,
@@ -85,6 +97,8 @@ export function CameraTile({
   totalLayers = null,
   printName = null,
   hmsErrorCount = 0,
+  temperatures = null,
+  timeFormat = 'system',
 }: CameraTileProps) {
   const { t } = useTranslation();
   const [bust, setBust] = useState(0);
@@ -269,6 +283,11 @@ export function CameraTile({
   const progressPct = progress != null ? Math.round(progress) : null;
   const hasLayers = layerNum != null && totalLayers != null && totalLayers > 0;
   const hasRemaining = remainingMin != null && remainingMin > 0;
+  // Heaters a printer has no sensor for are absent from its status, not 0.
+  const showTemps = connected && statusMode === 'full' && temperatures != null;
+  const nozzleTemps = [temperatures?.nozzle, temperatures?.nozzle_2].filter(
+    (v): v is number => v != null,
+  );
 
   const rootClass = `group relative aspect-video w-full overflow-hidden rounded-lg border border-bambu-dark-tertiary bg-black text-left ${
     interactive ? 'focus:outline-none focus:ring-2 focus:ring-bambu-green' : 'cursor-default'
@@ -375,9 +394,37 @@ export function CameraTile({
                   })}
                 </span>
               )}
+              {/* A paused print has no finish time: it moves with the pause. */}
+              {hasRemaining && bucket === 'printing' && (
+                <span>
+                  {t('printers.camWall.eta', { time: formatETA(remainingMin ?? 0, timeFormat, t) })}
+                </span>
+              )}
             </div>
           </div>
         )}
+        {showTemps && (
+          <div className="mb-0.5 flex flex-wrap items-center gap-x-2 text-[11px] leading-tight text-bambu-gray">
+            {nozzleTemps.length > 0 && (
+              <span className="flex items-center gap-0.5" title={t('printers.temperatures.nozzle')}>
+                <Flame className="h-3 w-3" aria-label={t('printers.temperatures.nozzle')} />
+                {nozzleTemps.map((v) => `${Math.round(v)}°`).join(' / ')}
+              </span>
+            )}
+            {temperatures?.bed != null && (
+              <span className="flex items-center gap-0.5" title={t('printers.temperatures.bed')}>
+                <RectangleHorizontal className="h-3 w-3" aria-label={t('printers.temperatures.bed')} />
+                {Math.round(temperatures.bed)}°
+              </span>
+            )}
+            {temperatures?.chamber != null && (
+              <span className="flex items-center gap-0.5" title={t('printers.temperatures.chamber')}>
+                <Box className="h-3 w-3" aria-label={t('printers.temperatures.chamber')} />
+                {Math.round(temperatures.chamber)}°
+              </span>
+            )}
+          </div>
+        )}
         <span className="block truncate text-xs font-medium">{printerName}</span>
       </div>
     </>

+ 173 - 0
frontend/src/components/CameraTileControls.tsx

@@ -0,0 +1,173 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { Pause, Play, Square } from 'lucide-react';
+import { api, type PrinterStatus } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+import { ConfirmModal } from './ConfirmModal';
+import { ChamberLight } from './icons/ChamberLight';
+
+interface CameraTileControlsProps {
+  printerId: number;
+  printerName: string;
+  connected: boolean;
+  printerState: string | null;
+  chamberLight: boolean | null;
+}
+
+const BUTTON =
+  'flex h-7 w-7 items-center justify-center rounded-md bg-black/70 text-white backdrop-blur-sm transition-colors hover:bg-black/90 focus:outline-none focus:ring-2 focus:ring-bambu-green disabled:cursor-not-allowed disabled:opacity-50';
+
+/**
+ * Pause/resume, stop and the chamber light on a camera wall tile (#3479).
+ *
+ * The same actions as the printer card's, behind the same printers:control
+ * permission and the same confirmations: a user without the permission gets
+ * no controls at all. Rendered beside the tile, not inside it, because the
+ * tile itself is a button (it opens the camera).
+ */
+export function CameraTileControls({
+  printerId,
+  printerName,
+  connected,
+  printerState,
+  chamberLight,
+}: CameraTileControlsProps) {
+  const { t } = useTranslation();
+  const { hasPermission } = useAuth();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const [confirm, setConfirm] = useState<'pause' | 'resume' | 'stop' | null>(null);
+
+  const refreshStatus = () => queryClient.invalidateQueries({ queryKey: ['printerStatus', printerId] });
+
+  const stop = useMutation({
+    mutationFn: () => api.stopPrint(printerId),
+    onSuccess: () => {
+      showToast(t('printers.toast.printStopped'));
+      refreshStatus();
+    },
+    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToStopPrint'), 'error'),
+  });
+  const pause = useMutation({
+    mutationFn: () => api.pausePrint(printerId),
+    onSuccess: () => {
+      showToast(t('printers.toast.printPaused'));
+      refreshStatus();
+    },
+    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToPausePrint'), 'error'),
+  });
+  const resume = useMutation({
+    mutationFn: () => api.resumePrint(printerId),
+    onSuccess: () => {
+      showToast(t('printers.toast.printResumed'));
+      refreshStatus();
+    },
+    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToResumePrint'), 'error'),
+  });
+  // Optimistic, as on the card: the light's state comes back over the
+  // printer's next status push, which can take a few seconds.
+  const light = useMutation({
+    mutationFn: (on: boolean) => api.setChamberLight(printerId, on),
+    onMutate: async (on) => {
+      await queryClient.cancelQueries({ queryKey: ['printerStatus', printerId] });
+      const previous = queryClient.getQueryData<PrinterStatus>(['printerStatus', printerId]);
+      if (previous) {
+        queryClient.setQueryData<PrinterStatus>(['printerStatus', printerId], { ...previous, chamber_light: on });
+      }
+      return { previous };
+    },
+    onError: (error: Error, _on, context) => {
+      if (context?.previous) queryClient.setQueryData(['printerStatus', printerId], context.previous);
+      showToast(error.message || t('printers.toast.failedToControlChamberLight'), 'error');
+    },
+  });
+
+  if (!hasPermission('printers:control') || !connected) return null;
+
+  const isPaused = printerState === 'PAUSE';
+  const isPrinting = printerState === 'RUNNING' || isPaused;
+  const busy = stop.isPending || pause.isPending || resume.isPending;
+  const lightOn = chamberLight ?? false;
+
+  return (
+    <>
+      <div className="flex gap-1">
+        {isPrinting && (
+          <>
+            <button
+              type="button"
+              className={BUTTON}
+              disabled={busy}
+              onClick={() => setConfirm(isPaused ? 'resume' : 'pause')}
+              title={isPaused ? t('printers.resume') : t('printers.pause')}
+              aria-label={isPaused ? t('printers.resume') : t('printers.pause')}
+            >
+              {isPaused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
+            </button>
+            <button
+              type="button"
+              className={`${BUTTON} hover:text-red-400`}
+              disabled={busy}
+              onClick={() => setConfirm('stop')}
+              title={t('printers.stop')}
+              aria-label={t('printers.stop')}
+            >
+              <Square className="h-3.5 w-3.5" />
+            </button>
+          </>
+        )}
+        <button
+          type="button"
+          className={BUTTON}
+          disabled={light.isPending}
+          aria-pressed={lightOn}
+          onClick={() => light.mutate(!lightOn)}
+          title={lightOn ? t('printers.chamberLightOff') : t('printers.chamberLightOn')}
+          aria-label={lightOn ? t('printers.chamberLightOff') : t('printers.chamberLightOn')}
+        >
+          <ChamberLight on={lightOn} className="h-4 w-4" />
+        </button>
+      </div>
+
+      {confirm === 'stop' && (
+        <ConfirmModal
+          title={t('printers.confirm.stopTitle')}
+          message={t('printers.confirm.stopMessage', { name: printerName })}
+          confirmText={t('printers.confirm.stopButton')}
+          variant="danger"
+          onConfirm={() => {
+            stop.mutate();
+            setConfirm(null);
+          }}
+          onCancel={() => setConfirm(null)}
+        />
+      )}
+      {confirm === 'pause' && (
+        <ConfirmModal
+          title={t('printers.confirm.pauseTitle')}
+          message={t('printers.confirm.pauseMessage', { name: printerName })}
+          confirmText={t('printers.confirm.pauseButton')}
+          onConfirm={() => {
+            pause.mutate();
+            setConfirm(null);
+          }}
+          onCancel={() => setConfirm(null)}
+        />
+      )}
+      {confirm === 'resume' && (
+        <ConfirmModal
+          title={t('printers.confirm.resumeTitle')}
+          message={t('printers.confirm.resumeMessage', { name: printerName })}
+          confirmText={t('printers.confirm.resumeButton')}
+          onConfirm={() => {
+            resume.mutate();
+            setConfirm(null);
+          }}
+          onCancel={() => setConfirm(null)}
+        />
+      )}
+    </>
+  );
+}

+ 77 - 0
frontend/src/components/CameraWall.tsx

@@ -4,8 +4,11 @@ import { useQueries } from '@tanstack/react-query';
 import { Settings as SettingsIcon } from 'lucide-react';
 import { DEFAULT_CAM_WALL_TILE_SIZE, camWallGridClasses } from './camWallTileSize';
 import { CameraTile, type CameraTileMode, type CameraTileStatusMode } from './CameraTile';
+import { CameraTileControls } from './CameraTileControls';
+import type { CamWallControlsMode } from './camWallControls';
 import { filterKnownHMSErrors } from './HMSErrorModal';
 import { api, type PrinterStatus } from '../api/client';
+import type { TimeFormat } from '../utils/date';
 
 // The wall only ever reads these three fields off a printer, so it asks for no
 // more than that. Printer[] satisfies this structurally, and so does the
@@ -30,6 +33,9 @@ export interface CameraWallStatus {
   subtask_name?: string | null;
   gcode_file?: string | null;
   hms_errors?: PrinterStatus['hms_errors'];
+  // Not in the kiosk feed: a kiosk tile shows neither (#3479).
+  temperatures?: PrinterStatus['temperatures'];
+  chamber_light?: boolean;
 }
 
 interface CameraWallProps {
@@ -53,6 +59,15 @@ interface CameraWallProps {
   // 1=S, 2=M, 3=L, 4=XL, the same scale as the printer cards (#2735). Sets how
   // many tiles share a row; the tiles stay 16:9.
   tileSize?: number;
+  // Pause/resume, stop and the chamber light on each tile (#3479), for a user
+  // with printers:control. Never on a kiosk wall: nobody stands at a TV, and
+  // its token is read-only.
+  showControls?: boolean;
+  // Whether those controls wait for hover or are always shown. The setting is
+  // offered only on a wall that has the controls.
+  controlsMode?: CamWallControlsMode;
+  onChangeControlsMode?: (next: CamWallControlsMode) => void;
+  timeFormat?: TimeFormat;
 }
 
 const MIN_MAX_LIVE = 1;
@@ -60,6 +75,7 @@ const MAX_MAX_LIVE = 16;
 const MIN_SNAPSHOT_SEC = 2;
 const MAX_SNAPSHOT_SEC = 60;
 const STATUS_MODES: CameraTileStatusMode[] = ['off', 'compact', 'full'];
+const CONTROLS_MODES: CamWallControlsMode[] = ['hover', 'always'];
 
 export function CameraWall({
   printers,
@@ -73,6 +89,10 @@ export function CameraWall({
   statuses,
   showSettings: settingsEnabled = true,
   tileSize = DEFAULT_CAM_WALL_TILE_SIZE,
+  showControls = false,
+  controlsMode = 'hover',
+  onChangeControlsMode,
+  timeFormat = 'system',
 }: CameraWallProps) {
   const { t } = useTranslation();
   const tileRefs = useRef<Map<number, HTMLDivElement | null>>(new Map());
@@ -270,6 +290,38 @@ export function CameraWall({
                   {t('printers.camWall.settings.statusOverlayHint')}
                 </span>
               </div>
+              {showControls && onChangeControlsMode && (
+                <div className="space-y-1">
+                  <span className="block text-xs font-medium text-white">
+                    {t('printers.camWall.settings.controls')}
+                  </span>
+                  <div
+                    role="radiogroup"
+                    aria-label={t('printers.camWall.settings.controls')}
+                    className="flex overflow-hidden rounded-md border border-bambu-dark-tertiary"
+                  >
+                    {CONTROLS_MODES.map((m) => (
+                      <button
+                        key={m}
+                        type="button"
+                        role="radio"
+                        aria-checked={controlsMode === m}
+                        onClick={() => onChangeControlsMode(m)}
+                        className={`flex-1 px-2 py-1 text-xs ${
+                          controlsMode === m
+                            ? 'bg-bambu-green text-black font-semibold'
+                            : 'bg-bambu-dark text-white hover:bg-bambu-dark-tertiary'
+                        }`}
+                      >
+                        {t(`printers.camWall.controlsMode.${m}`)}
+                      </button>
+                    ))}
+                  </div>
+                  <span className="block text-[11px] text-bambu-gray">
+                    {t('printers.camWall.settings.controlsHint')}
+                  </span>
+                </div>
+              )}
             </div>
           )}
         </div>
@@ -279,6 +331,7 @@ export function CameraWall({
       <div className={`grid gap-3 ${camWallGridClasses(tileSize)}`} data-testid="camwall-grid">
         {printers.map((p) => {
           const mode = modeByPrinter.get(p.id) ?? 'paused';
+          const status = statusByPrinter.get(p.id);
           return (
             <div
               key={p.id}
@@ -286,6 +339,7 @@ export function CameraWall({
                 tileRefs.current.set(p.id, el);
               }}
               data-printer-id={p.id}
+              className="group/tile relative"
             >
               <CameraTile
                 printerId={p.id}
@@ -308,8 +362,31 @@ export function CameraWall({
                 hmsErrorCount={
                   filterKnownHMSErrors(statusByPrinter.get(p.id)?.hms_errors ?? []).length
                 }
+                temperatures={status?.temperatures ?? null}
+                timeFormat={timeFormat}
                 onClick={onTileClick ? () => onTileClick(p.id, p.name) : undefined}
               />
+              {/* Beside the tile, not in it: the tile is a button. Shown on
+                  hover or focus where there is a pointer, always on a touch
+                  screen, which has no hover. */}
+              {showControls && (
+                <div
+                  data-testid="camwall-tile-controls"
+                  className={`absolute right-2 top-8 ${
+                    controlsMode === 'always'
+                      ? ''
+                      : 'opacity-0 transition-opacity focus-within:opacity-100 group-hover/tile:opacity-100 [@media(hover:none)]:opacity-100'
+                  }`}
+                >
+                  <CameraTileControls
+                    printerId={p.id}
+                    printerName={p.name}
+                    connected={status?.connected ?? false}
+                    printerState={status?.state ?? null}
+                    chamberLight={status?.chamber_light ?? null}
+                  />
+                </div>
+              )}
             </div>
           );
         })}

+ 14 - 0
frontend/src/components/camWallControls.ts

@@ -0,0 +1,14 @@
+// When a Cam Wall tile shows its controls (#3479): on hover (and focus), so the
+// picture stays clear, or always. Touch screens have no hover and always show
+// them. Stored per browser, like the wall's other settings.
+export type CamWallControlsMode = 'hover' | 'always';
+
+export const CAM_WALL_CONTROLS_KEY = 'camWallControls';
+
+export function savedCamWallControlsMode(): CamWallControlsMode {
+  return localStorage.getItem(CAM_WALL_CONTROLS_KEY) === 'always' ? 'always' : 'hover';
+}
+
+export function saveCamWallControlsMode(mode: CamWallControlsMode): void {
+  localStorage.setItem(CAM_WALL_CONTROLS_KEY, mode);
+}

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

@@ -379,11 +379,16 @@ export default {
       summary: '{{live}} live, {{snap}} Schnappschüsse, {{total}} insgesamt',
       layer: 'Schicht {{cur}}/{{total}}',
       timeLeft: 'noch {{time}}',
+      eta: 'fertig {{time}}',
       statusMode: {
         off: 'Aus',
         compact: 'Kompakt',
         full: 'Voll',
       },
+      controlsMode: {
+        hover: 'Beim Überfahren',
+        always: 'Immer',
+      },
       settings: {
         title: 'Kamera-Wand-Einstellungen',
         maxLive: 'Max. Live-Streams',
@@ -391,7 +396,9 @@ export default {
         snapshotInterval: 'Schnappschuss-Intervall (Sekunden)',
         snapshotIntervalHint: 'Wie oft Nicht-Live-Kacheln einen neuen Schnappschuss abrufen.',
         statusOverlay: 'Status-Overlay',
-        statusOverlayHint: 'Kompakt: nur Status-Plakette. Voll: + Fortschritt, Schicht, Restzeit.',
+        statusOverlayHint: 'Kompakt: nur Status-Plakette. Voll: + Fortschritt, Schicht, Restzeit, Fertigstellung, Temperaturen.',
+        controls: 'Kachel-Steuerung',
+        controlsHint: 'Pause, Stopp und Kammerlicht auf jeder Kachel: beim Überfahren oder immer. Touchscreens zeigen sie immer.',
       },
     },
     // Controls

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

@@ -381,11 +381,16 @@ export default {
       summary: '{{live}} live, {{snap}} snapshots, {{total}} total',
       layer: 'Layer {{cur}}/{{total}}',
       timeLeft: '{{time}} left',
+      eta: 'ETA {{time}}',
       statusMode: {
         off: 'Off',
         compact: 'Compact',
         full: 'Full',
       },
+      controlsMode: {
+        hover: 'On hover',
+        always: 'Always',
+      },
       settings: {
         title: 'Cam wall settings',
         maxLive: 'Max live streams',
@@ -393,7 +398,9 @@ export default {
         snapshotInterval: 'Snapshot interval (seconds)',
         snapshotIntervalHint: 'How often non-live tiles fetch a fresh snapshot.',
         statusOverlay: 'Status overlay',
-        statusOverlayHint: 'Compact: state badge only. Full: + progress, layer, time left.',
+        statusOverlayHint: 'Compact: state badge only. Full: + progress, layer, time left, finish time, temperatures.',
+        controls: 'Tile controls',
+        controlsHint: 'Pause, stop and the chamber light on each tile: on hover, or always. Touch screens always show them.',
       },
     },
     // Controls

+ 8 - 1
frontend/src/i18n/locales/es.ts

@@ -379,11 +379,16 @@ export default {
       summary: '{{live}} en vivo, {{snap}} fotos, {{total}} en total',
       layer: 'Capa {{cur}}/{{total}}',
       timeLeft: 'quedan {{time}}',
+      eta: 'termina {{time}}',
       statusMode: {
         off: 'Apagado',
         compact: 'Compacto',
         full: 'Completo',
       },
+      controlsMode: {
+        hover: 'Al pasar el cursor',
+        always: 'Siempre',
+      },
       settings: {
         title: 'Ajustes del muro de cámaras',
         maxLive: 'Máx. transmisiones en vivo',
@@ -391,7 +396,9 @@ export default {
         snapshotInterval: 'Intervalo de fotos (segundos)',
         snapshotIntervalHint: 'Con qué frecuencia los mosaicos no en vivo obtienen una nueva foto.',
         statusOverlay: 'Superposición de estado',
-        statusOverlayHint: 'Compacto: solo insignia de estado. Completo: + progreso, capa, tiempo restante.',
+        statusOverlayHint: 'Compacto: solo insignia de estado. Completo: + progreso, capa, tiempo restante, hora de fin, temperaturas.',
+        controls: 'Controles del mosaico',
+        controlsHint: 'Pausa, detener y luz de cámara en cada mosaico: al pasar el cursor o siempre. Las pantallas táctiles siempre los muestran.',
       },
     },
     // Controls

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

@@ -379,11 +379,16 @@ export default {
       summary: '{{live}} en direct, {{snap}} captures, {{total}} au total',
       layer: 'Couche {{cur}}/{{total}}',
       timeLeft: '{{time}} restantes',
+      eta: 'fin {{time}}',
       statusMode: {
         off: 'Arrêt',
         compact: 'Compact',
         full: 'Complet',
       },
+      controlsMode: {
+        hover: 'Au survol',
+        always: 'Toujours',
+      },
       settings: {
         title: 'Paramètres du mur de caméras',
         maxLive: 'Flux en direct max.',
@@ -391,7 +396,9 @@ export default {
         snapshotInterval: 'Intervalle de capture (secondes)',
         snapshotIntervalHint: 'À quelle fréquence les vignettes hors direct récupèrent une nouvelle capture.',
         statusOverlay: 'Overlay de statut',
-        statusOverlayHint: 'Compact : badge de statut seul. Complet : + progression, couche, temps restant.',
+        statusOverlayHint: 'Compact : badge de statut seul. Complet : + progression, couche, temps restant, heure de fin, températures.',
+        controls: 'Commandes des tuiles',
+        controlsHint: 'Pause, arrêt et éclairage de la chambre sur chaque tuile : au survol ou toujours. Les écrans tactiles les affichent toujours.',
       },
     },
     // Controls

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

@@ -379,11 +379,16 @@ export default {
       summary: '{{live}} live, {{snap}} foto, {{total}} totali',
       layer: 'Strato {{cur}}/{{total}}',
       timeLeft: '{{time}} rimanenti',
+      eta: 'fine {{time}}',
       statusMode: {
         off: 'Off',
         compact: 'Compatto',
         full: 'Completo',
       },
+      controlsMode: {
+        hover: 'Al passaggio del mouse',
+        always: 'Sempre',
+      },
       settings: {
         title: 'Impostazioni muro telecamere',
         maxLive: 'Max stream live',
@@ -391,7 +396,9 @@ export default {
         snapshotInterval: 'Intervallo foto (secondi)',
         snapshotIntervalHint: 'Con quale frequenza le tessere non live scaricano una nuova foto.',
         statusOverlay: 'Overlay di stato',
-        statusOverlayHint: 'Compatto: solo badge di stato. Completo: + avanzamento, strato, tempo residuo.',
+        statusOverlayHint: 'Compatto: solo badge di stato. Completo: + avanzamento, strato, tempo residuo, ora di fine, temperature.',
+        controls: 'Controlli del riquadro',
+        controlsHint: 'Pausa, stop e luce della camera su ogni riquadro: al passaggio del mouse o sempre. Gli schermi touch li mostrano sempre.',
       },
     },
     // Controls

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

@@ -378,11 +378,16 @@ export default {
       summary: 'ライブ {{live}}件、スナップ {{snap}}件、合計 {{total}}件',
       layer: 'レイヤー {{cur}}/{{total}}',
       timeLeft: '残り {{time}}',
+      eta: '完了予定 {{time}}',
       statusMode: {
         off: 'オフ',
         compact: 'コンパクト',
         full: 'フル',
       },
+      controlsMode: {
+        hover: 'ホバー時',
+        always: '常に',
+      },
       settings: {
         title: 'カメラウォール設定',
         maxLive: '最大ライブ配信数',
@@ -390,7 +395,9 @@ export default {
         snapshotInterval: 'スナップショット間隔(秒)',
         snapshotIntervalHint: '非ライブのタイルが新しいスナップショットを取得する頻度。',
         statusOverlay: 'ステータス表示',
-        statusOverlayHint: 'コンパクト:状態バッジのみ。フル:+進捗・レイヤー・残り時間。',
+        statusOverlayHint: 'コンパクト:状態バッジのみ。フル:+進捗・レイヤー・残り時間・完了予定時刻・温度。',
+        controls: 'タイルの操作',
+        controlsHint: '各タイルの一時停止・停止・庫内ライト:ホバー時または常に表示。タッチ画面では常に表示されます。',
       },
     },
     // Controls

+ 8 - 1
frontend/src/i18n/locales/ko.ts

@@ -365,11 +365,16 @@ export default {
       summary: '라이브 {{live}}개, 스냅 {{snap}}개, 총 {{total}}개',
       layer: '레이어 {{cur}}/{{total}}',
       timeLeft: '{{time}} 남음',
+      eta: '완료 예정 {{time}}',
       statusMode: {
         off: '꺼짐',
         compact: '간단',
         full: '전체'
       },
+      controlsMode: {
+        hover: '마우스를 올렸을 때',
+        always: '항상',
+      },
       settings: {
         title: '카메라 월 설정',
         maxLive: '최대 라이브 스트림',
@@ -377,7 +382,9 @@ export default {
         snapshotInterval: '스냅샷 간격(초)',
         snapshotIntervalHint: '비라이브 타일이 새 스냅샷을 가져오는 주기.',
         statusOverlay: '상태 표시',
-        statusOverlayHint: '간단: 상태 배지만 표시. 전체: + 진행률, 레이어, 남은 시간.'
+        statusOverlayHint: '간단: 상태 배지만 표시. 전체: + 진행률, 레이어, 남은 시간, 완료 예정 시각, 온도.',
+        controls: '타일 컨트롤',
+        controlsHint: '각 타일의 일시정지, 정지, 챔버 조명: 마우스를 올렸을 때 또는 항상. 터치스크린에서는 항상 표시됩니다.',
       }
     },
     hideOffline: '오프라인 숨기기',

+ 8 - 1
frontend/src/i18n/locales/nl.ts

@@ -379,11 +379,16 @@ export default {
       summary: '{{live}} live, {{snap}} momentopnamen, {{total}} totaal',
       layer: 'Laag {{cur}}/{{total}}',
       timeLeft: '{{time}} resterend',
+      eta: 'klaar {{time}}',
       statusMode: {
         off: 'Uit',
         compact: 'Compact',
         full: 'Volledig',
       },
+      controlsMode: {
+        hover: 'Bij aanwijzen',
+        always: 'Altijd',
+      },
       settings: {
         title: 'Instellingen camerawand',
         maxLive: 'Max. live streams',
@@ -391,7 +396,9 @@ export default {
         snapshotInterval: 'Interval momentopnamen (seconden)',
         snapshotIntervalHint: 'Hoe vaak niet-live tegels een nieuwe momentopname ophalen.',
         statusOverlay: 'Statusoverlay',
-        statusOverlayHint: 'Compact: alleen statusbadge. Volledig: + voortgang, laag, resterende tijd.',
+        statusOverlayHint: 'Compact: alleen statusbadge. Volledig: + voortgang, laag, resterende tijd, eindtijd, temperaturen.',
+        controls: 'Tegelbediening',
+        controlsHint: 'Pauzeren, stoppen en kamerlicht op elke tegel: bij aanwijzen of altijd. Touchscreens tonen ze altijd.',
       },
     },
     // Controls

+ 8 - 1
frontend/src/i18n/locales/pl.ts

@@ -381,11 +381,16 @@ export default {
       summary: 'Na żywo: {{live}}, zdjęcia: {{snap}}, razem: {{total}}',
       layer: 'Warstwa {{cur}}/{{total}}',
       timeLeft: 'Pozostało {{time}}',
+      eta: 'koniec {{time}}',
       statusMode: {
         off: 'Wył.',
         compact: 'Kompaktowy',
         full: 'Pełny',
       },
+      controlsMode: {
+        hover: 'Po najechaniu',
+        always: 'Zawsze',
+      },
       settings: {
         title: 'Ustawienia ściany kamer',
         maxLive: 'Maks. strumieni na żywo',
@@ -393,7 +398,9 @@ export default {
         snapshotInterval: 'Interwał zdjęć (sekundy)',
         snapshotIntervalHint: 'Jak często kafelki bez obrazu na żywo pobierają nowe zdjęcie.',
         statusOverlay: 'Nakładka stanu',
-        statusOverlayHint: 'Kompaktowa: tylko znacznik stanu. Pełna: + postęp, warstwa, pozostały czas.',
+        statusOverlayHint: 'Kompaktowa: tylko znacznik stanu. Pełna: + postęp, warstwa, pozostały czas, godzina zakończenia, temperatury.',
+        controls: 'Sterowanie na kafelku',
+        controlsHint: 'Pauza, zatrzymanie i oświetlenie komory na każdym kafelku: po najechaniu kursorem lub zawsze. Ekrany dotykowe pokazują je zawsze.',
       },
     },
     // Controls

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

@@ -379,11 +379,16 @@ export default {
       summary: '{{live}} ao vivo, {{snap}} fotos, {{total}} no total',
       layer: 'Camada {{cur}}/{{total}}',
       timeLeft: 'faltam {{time}}',
+      eta: 'termina {{time}}',
       statusMode: {
         off: 'Desligado',
         compact: 'Compacto',
         full: 'Completo',
       },
+      controlsMode: {
+        hover: 'Ao passar o mouse',
+        always: 'Sempre',
+      },
       settings: {
         title: 'Configurações do mural de câmeras',
         maxLive: 'Máx. transmissões ao vivo',
@@ -391,7 +396,9 @@ export default {
         snapshotInterval: 'Intervalo de foto (segundos)',
         snapshotIntervalHint: 'Com que frequência os blocos não ao vivo buscam uma nova foto.',
         statusOverlay: 'Sobreposição de status',
-        statusOverlayHint: 'Compacto: apenas o selo de status. Completo: + progresso, camada e tempo restante.',
+        statusOverlayHint: 'Compacto: apenas o selo de status. Completo: + progresso, camada, tempo restante, horário de término e temperaturas.',
+        controls: 'Controles do bloco',
+        controlsHint: 'Pausar, parar e luz da câmara em cada bloco: ao passar o mouse ou sempre. Telas sensíveis ao toque sempre os mostram.',
       },
     },
     // Controls

+ 8 - 1
frontend/src/i18n/locales/ru.ts

@@ -370,11 +370,16 @@ export default {
       summary: "Онлайн: {{live}}, снимки: {{snap}}, всего: {{total}}",
       layer: "Слой {{cur}}/{{total}}",
       timeLeft: "Осталось {{time}}",
+      eta: "завершение {{time}}",
       statusMode: {
         off: "Выкл.",
         compact: "Компактный",
         full: "Полный",
       },
+      controlsMode: {
+        hover: "При наведении",
+        always: "Всегда",
+      },
       settings: {
         title: "Настройки стены камер",
         maxLive: "Максимум онлайн-трансляций",
@@ -382,7 +387,9 @@ export default {
         snapshotInterval: "Интервал снимков, с",
         snapshotIntervalHint: "Как часто обновлять снимки камер без онлайн-трансляции.",
         statusOverlay: "Наложение состояния",
-        statusOverlayHint: "Компактный режим: только состояние. Полный: прогресс, слой и оставшееся время.",
+        statusOverlayHint: "Компактный режим: только состояние. Полный: прогресс, слой, оставшееся время, время завершения и температуры.",
+        controls: "Управление на плитке",
+        controlsHint: "Пауза, остановка и подсветка камеры на каждой плитке: при наведении или всегда. На сенсорных экранах они видны всегда.",
       },
     },
     hideOffline: "Скрыть принтеры не в сети",

+ 8 - 1
frontend/src/i18n/locales/sv.ts

@@ -379,11 +379,16 @@ export default {
       summary: '{{live}} live, {{snap}} ögonblicksbilder, {{total}} totalt',
       layer: 'Skikt {{cur}}/{{total}}',
       timeLeft: '{{time}} kvar',
+      eta: 'klar {{time}}',
       statusMode: {
         off: 'Av',
         compact: 'Kompakt',
         full: 'Fullständig',
       },
+      controlsMode: {
+        hover: 'Vid hovring',
+        always: 'Alltid',
+      },
       settings: {
         title: 'Inställningar för kameravägg',
         maxLive: 'Max antal live-sändningar',
@@ -391,7 +396,9 @@ export default {
         snapshotInterval: 'Intervall för ögonblicksbilder (sekunder)',
         snapshotIntervalHint: 'Hur ofta icke-live-rutor hämtar en ny ögonblicksbild.',
         statusOverlay: 'Statusöverlägg',
-        statusOverlayHint: 'Kompakt: Endast statusmärke. Fullständig: + framsteg, skikt, tid kvar.',
+        statusOverlayHint: 'Kompakt: Endast statusmärke. Fullständig: + framsteg, skikt, tid kvar, sluttid, temperaturer.',
+        controls: 'Rutkontroller',
+        controlsHint: 'Paus, stopp och kammarbelysning på varje ruta: vid hovring eller alltid. Pekskärmar visar dem alltid.',
       },
     },
     // Controls

+ 8 - 1
frontend/src/i18n/locales/tr.ts

@@ -379,11 +379,16 @@ export default {
       summary: '{{live}} canlı, {{snap}} fotoğraf, toplam {{total}}',
       layer: 'Katman {{cur}}/{{total}}',
       timeLeft: '{{time}} kaldı',
+      eta: 'bitiş {{time}}',
       statusMode: {
         off: 'Kapalı',
         compact: 'Sade',
         full: 'Tam',
       },
+      controlsMode: {
+        hover: 'Üzerine gelince',
+        always: 'Her zaman',
+      },
       settings: {
         title: 'Kamera duvarı ayarları',
         maxLive: 'Maks. canlı yayın',
@@ -391,7 +396,9 @@ export default {
         snapshotInterval: 'Foto aralığı (saniye)',
         snapshotIntervalHint: 'Canlı olmayan döşemelerin ne sıklıkla yeni bir foto aldığı.',
         statusOverlay: 'Durum kaplaması',
-        statusOverlayHint: 'Sade: yalnızca durum rozeti. Tam: + ilerleme, katman, kalan süre.',
+        statusOverlayHint: 'Sade: yalnızca durum rozeti. Tam: + ilerleme, katman, kalan süre, bitiş saati, sıcaklıklar.',
+        controls: 'Kutucuk denetimleri',
+        controlsHint: 'Her kutucukta duraklat, durdur ve hazne ışığı: üzerine gelince veya her zaman. Dokunmatik ekranlar bunları her zaman gösterir.',
       },
     },
     // Kontroller

+ 8 - 1
frontend/src/i18n/locales/uk.ts

@@ -379,11 +379,16 @@ export default {
       summary: "{{live}} наживо, {{snap}} знімки, {{total}} всього",
       layer: "Шар {{cur}}/{{total}}",
       timeLeft: "Залишилося {{time}}",
+      eta: "завершення {{time}}",
       statusMode: {
         off: "Вимкнено",
         compact: "Компактний",
         full: "Повний",
       },
+      controlsMode: {
+        hover: "Під час наведення",
+        always: "Завжди",
+      },
       settings: {
         title: "Налаштування Cam Wall",
         maxLive: "Максимальна кількість прямих трансляцій",
@@ -391,7 +396,9 @@ export default {
         snapshotInterval: "Інтервал знімка (секунди)",
         snapshotIntervalHint: "Як часто плитки без прямої трансляції отримують новий знімок.",
         statusOverlay: "Накладення стану",
-        statusOverlayHint: "Компактний: лише значок стану. Повний: значок, прогрес, шар і час, що залишився.",
+        statusOverlayHint: "Компактний: лише значок стану. Повний: значок, прогрес, шар, час, що залишився, час завершення і температури.",
+        controls: "Керування на плитці",
+        controlsHint: "Пауза, зупинка й підсвічування камери на кожній плитці: під час наведення або завжди. На сенсорних екранах вони видимі завжди.",
       },
     },
     // Controls

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

@@ -379,11 +379,16 @@ export default {
       summary: '直播 {{live}} 个,快照 {{snap}} 个,共 {{total}} 个',
       layer: '第 {{cur}}/{{total}} 层',
       timeLeft: '剩余 {{time}}',
+      eta: '预计完成 {{time}}',
       statusMode: {
         off: '关闭',
         compact: '简洁',
         full: '完整',
       },
+      controlsMode: {
+        hover: '悬停时',
+        always: '始终',
+      },
       settings: {
         title: '摄像头墙设置',
         maxLive: '最大直播数',
@@ -391,7 +396,9 @@ export default {
         snapshotInterval: '快照刷新间隔(秒)',
         snapshotIntervalHint: '非直播画面获取新快照的频率。',
         statusOverlay: '状态叠加',
-        statusOverlayHint: '简洁:仅显示状态标签。完整:加上进度、层数、剩余时间。',
+        statusOverlayHint: '简洁:仅显示状态标签。完整:加上进度、层数、剩余时间、预计完成时间、温度。',
+        controls: '磁贴控制',
+        controlsHint: '每个磁贴上的暂停、停止和腔体灯:悬停时显示或始终显示。触摸屏始终显示。',
       },
     },
     // Controls

+ 8 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -379,11 +379,16 @@ export default {
       summary: '直播 {{live}} 個,快照 {{snap}} 個,共 {{total}} 個',
       layer: '第 {{cur}}/{{total}} 層',
       timeLeft: '剩餘 {{time}}',
+      eta: '預計完成 {{time}}',
       statusMode: {
         off: '關閉',
         compact: '精簡',
         full: '完整',
       },
+      controlsMode: {
+        hover: '懸停時',
+        always: '一律',
+      },
       settings: {
         title: '攝影機牆設定',
         maxLive: '最大直播數',
@@ -391,7 +396,9 @@ export default {
         snapshotInterval: '快照重新整理間隔(秒)',
         snapshotIntervalHint: '非直播畫面取得新快照的頻率。',
         statusOverlay: '狀態疊加',
-        statusOverlayHint: '精簡:僅顯示狀態標籤。完整:加上進度、層數、剩餘時間。',
+        statusOverlayHint: '精簡:僅顯示狀態標籤。完整:加上進度、層數、剩餘時間、預計完成時間、溫度。',
+        controls: '圖塊控制',
+        controlsHint: '每個圖塊上的暫停、停止和腔體燈:懸停時顯示或一律顯示。觸控螢幕一律顯示。',
       },
     },
     // Controls

+ 17 - 0
frontend/src/pages/CamWallPage.tsx

@@ -26,10 +26,12 @@ import { useTranslation } from 'react-i18next';
 import { CameraWall, type CameraWallStatus } from '../components/CameraWall';
 import { CAM_WALL_TILE_SIZES, DEFAULT_CAM_WALL_TILE_SIZE } from '../components/camWallTileSize';
 import { type CameraTileStatusMode } from '../components/CameraTile';
+import { savedCamWallControlsMode, saveCamWallControlsMode } from '../components/camWallControls';
 import { api, setStreamToken } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 import { AuthRetrying } from '../components/AuthRetrying';
 import { isStandaloneWebApp } from '../utils/camera';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 // Kiosk polling cadence. Matches the staleTime the in-page wall runs at, so a
 // tile's chip is never more stale than it would be on the Printers page.
@@ -97,6 +99,7 @@ export function CamWallPage() {
     ),
   );
   const [tileSize] = useState(() => tileSizeFrom(searchParams));
+  const [controlsMode, setControlsMode] = useState(savedCamWallControlsMode);
   const [statusMode, setStatusMode] = useState<CameraTileStatusMode>(() => {
     const requested = searchParams.get('status') ?? localStorage.getItem('camWallStatusMode');
     // 'full' names the file on the bed. Fine on a signed-in screen, wrong on one
@@ -135,6 +138,11 @@ export function CamWallPage() {
     refetchInterval: KIOSK_POLL_MS,
   });
 
+  // The finish time on the tiles follows the user's 12h/24h choice (#3479).
+  // A kiosk token reaches no settings: its tiles show no finish time anyway.
+  const signedIn = !kiosk && !authLoading && (!authEnabled || user !== null);
+  const { data: uiSettings } = useUiSettings({ enabled: signedIn });
+
   const kioskStatuses = useMemo(() => {
     const map = new Map<number, CameraWallStatus | undefined>();
     for (const p of kioskQuery.data ?? []) {
@@ -191,6 +199,15 @@ export function CamWallPage() {
         tileSize={tileSize}
         statuses={kiosk ? kioskStatuses : undefined}
         showSettings={!kiosk}
+        // Read-only on a kiosk wall: its token cannot control a printer, and
+        // nobody stands at the screen (#3479).
+        showControls={!kiosk}
+        controlsMode={controlsMode}
+        onChangeControlsMode={(next) => {
+          setControlsMode(next);
+          saveCamWallControlsMode(next);
+        }}
+        timeFormat={uiSettings?.time_format || 'system'}
         // A home-screen web app has no second window: the camera opens in
         // place and its back button returns here (#3419).
         onTileClick={

+ 9 - 0
frontend/src/pages/PrintersPage.tsx

@@ -175,6 +175,7 @@ import { FileManagerModal } from '../components/FileManagerModal';
 import { useEmbeddedCameras } from '../contexts/EmbeddedCameraContext';
 import { CameraWall } from '../components/CameraWall';
 import { DEFAULT_CAM_WALL_TILE_SIZE } from '../components/camWallTileSize';
+import { savedCamWallControlsMode, saveCamWallControlsMode, type CamWallControlsMode } from '../components/camWallControls';
 import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal, filterKnownHMSErrors, isSevereHMSError } from '../components/HMSErrorModal';
@@ -8951,6 +8952,7 @@ export function PrintersPage() {
     const saved = parseInt(localStorage.getItem('camWallTileSize') || '', 10);
     return saved >= 1 && saved <= 4 ? saved : DEFAULT_CAM_WALL_TILE_SIZE;
   });
+  const [camWallControlsMode, setCamWallControlsMode] = useState<CamWallControlsMode>(savedCamWallControlsMode);
   const [camWallStatusMode, setCamWallStatusMode] = useState<'off' | 'compact' | 'full'>(() => {
     const saved = localStorage.getItem('camWallStatusMode');
     return saved === 'off' || saved === 'compact' || saved === 'full' ? saved : 'full';
@@ -10014,6 +10016,13 @@ export function PrintersPage() {
             }
           }}
           statusMode={camWallStatusMode}
+          showControls
+          controlsMode={camWallControlsMode}
+          onChangeControlsMode={(next) => {
+            setCamWallControlsMode(next);
+            saveCamWallControlsMode(next);
+          }}
+          timeFormat={settings?.time_format || 'system'}
           onChangeMaxLive={(next) => {
             setCamWallMaxLive(next);
             localStorage.setItem('camWallMaxLive', String(next));

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ArchivesPage-JFcyEgzs.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/CamWallPage-BzrTfqQP.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/CamWallPage-UV48uNNm.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/CameraPage-DYCavmhk.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/CameraWall-D46f3IC6.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/EmbeddedCameraViewer-CAusapsF.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/FileManagerPage-D4tYnefa.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/InventoryPage-CeOCbp8N.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/Layout-DQdP8ELS.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/Layout-DaLZ1Hi3.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ModelSourcesPage-Dxh4JFUk.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/PrintersPage-A4Lovfbi.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/PrintersPage-BrT8aKnl.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/SettingsPage-CHx4Lvii.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/SkipObjectsModal-BcQGxu6h.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/SkipObjectsModal-DhyycWv0.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/SliceModal-reQHg7hG.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-4sjIwYij.js


Файловите разлики са ограничени, защото са твърде много
+ 1 - 1
static/assets/SpoolBuddySettingsPage-DatFcZUl.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/camWallControls-Gm6zLwmD.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/de-B-_RnsT_.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/de-D-Ik6cQi.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/es-B7Qrxs22.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/es-PKf4Cad_.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/fr-B7wZdUbJ.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/fr-nDqI16Sc.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/index-CPjehcO9.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/index-vGjPx3LJ.css


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/it-B3t9J8b4.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/it-DZkkk939.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ja-CH8ApWLn.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ja-DGyQ2YrU.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ko-Bi0wUs5y.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ko-s7c341YZ.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/locationSensorDefaults-Ba0BKrgT.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/nl-C5HKndvZ.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/nl-DtlISEPr.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/pl-BNF_Q2Qv.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/pl-BSF5cGtV.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 1
static/assets/plateCheck-DOHThmIE.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/pt-BR-BGrtweOA.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/pt-BR-JuKchEHn.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ru-BijYtPGG.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ru-By63XKsD.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/spoolCost-BSkLxT27.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/sv-BeYXjkxq.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/sv-DE21FGoi.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/tr-BOyUwTuE.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/tr-CDRrfHTL.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/uk-C5MfpycI.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/uk-D6-hB6zN.js


+ 0 - 1
static/assets/useCameraViewerId-BG_jtrZ0.js

@@ -1 +0,0 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t}from"./client-A76-KVrT.js";var n=e(t(),1);function r(){let[e]=(0,n.useState)(()=>`v${Math.random().toString(36).slice(2,12)}`);return e}export{r as t};

+ 1 - 0
static/assets/useCameraViewerId-BVlJicPn.js

@@ -0,0 +1 @@
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-A76-KVrT.js";var r=n();function i({on:e,className:t=`w-5 h-5`}){let n=e?`#facc15`:`none`,i=e?`#78350f`:`currentColor`;return(0,r.jsxs)(`svg`,{viewBox:`0 0 32 32`,fill:`none`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,className:t,children:[(0,r.jsxs)(`g`,{stroke:i,opacity:+!!e,children:[(0,r.jsx)(`line`,{x1:`16`,y1:`2`,x2:`16`,y2:`6`}),(0,r.jsx)(`line`,{x1:`6.1`,y1:`6.1`,x2:`8.9`,y2:`8.9`}),(0,r.jsx)(`line`,{x1:`25.9`,y1:`6.1`,x2:`23.1`,y2:`8.9`}),(0,r.jsx)(`line`,{x1:`2`,y1:`16`,x2:`6`,y2:`16`}),(0,r.jsx)(`line`,{x1:`30`,y1:`16`,x2:`26`,y2:`16`})]}),(0,r.jsx)(`path`,{d:`M12 24v-2.3c0-.9-.4-1.7-1-2.3C9.2 17.6 8 15.4 8 13c0-4.4 3.6-8 8-8s8 3.6 8 8c0 2.4-1.2 4.6-3 6.4-.6.6-1 1.4-1 2.3V24`,fill:n,stroke:i}),(0,r.jsx)(`path`,{d:`M12 24h8`,stroke:i}),(0,r.jsx)(`path`,{d:`M12 27h8`,stroke:i}),(0,r.jsx)(`path`,{d:`M13 30h6`,stroke:i})]})}var a=e(t(),1);function o(){let[e]=(0,a.useState)(()=>`v${Math.random().toString(36).slice(2,12)}`);return e}export{i as n,o as t};

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/usePageFileDrop-DN2gT4m3.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/zh-CN-CUkTFdpC.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/zh-CN-DQh4mrY6.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/zh-TW-DxiHu4YR.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/zh-TW-wjTdiZua.js


+ 4 - 4
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-B8I0vmF5.js"></script>
+    <script type="module" crossorigin src="/assets/index-CPjehcO9.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-A76-KVrT.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-82e9FLNr.js">
@@ -37,7 +37,7 @@
     <link rel="modulepreload" crossorigin href="/assets/info-BD7StG1O.js">
     <link rel="modulepreload" crossorigin href="/assets/AuthContext-57h2rDlo.js">
     <link rel="modulepreload" crossorigin href="/assets/camera-MsEzBYBi.js">
-    <link rel="modulepreload" crossorigin href="/assets/plateCheck-DziO5lKr.js">
+    <link rel="modulepreload" crossorigin href="/assets/plateCheck-DOHThmIE.js">
     <link rel="modulepreload" crossorigin href="/assets/useMutation-BVCKaUQ-.js">
     <link rel="modulepreload" crossorigin href="/assets/useQueries-CNxo4TuT.js">
     <link rel="modulepreload" crossorigin href="/assets/archive-B-1F8N5G.js">
@@ -122,8 +122,8 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-D6NM9pNL.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-6gmNNa1B.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-CHLiTxan.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-DaLZ1Hi3.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-C2XdLYj2.css">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-DQdP8ELS.js">
+    <link rel="stylesheet" crossorigin href="/assets/index-vGjPx3LJ.css">
   </head>
   <body>
     <div id="root"></div>

Някои файлове не бяха показани, защото твърде много файлове са промени