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

Let the S/M/L/XL selector size the Cam Wall tiles (#2735)

The size selector greyed out on the Cam Wall, whose grid always put up to
four tiles in a row, so a two-printer setup got two small tiles side by
side. In Cam Wall mode the selector now sets the tile grid (S up to five
per row, M four as before, L two, XL one full-width tile), saved apart
from the card size. The standalone /camwall page follows the last pick
and takes ?size=s|m|l|xl for kiosk links.
maziggy 2 дней назад
Родитель
Сommit
0054875d18

+ 4 - 0
CHANGELOG.md

@@ -5,6 +5,10 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 ## [1.2.6b1] - Unreleased
 
 
 ### Added
 ### Added
+- **The Cam Wall's tiles can be resized with the S / M / L / XL selector (#2735, requested by @cmerkle)** — The size selector on the Printers page used to grey out on the Cam Wall, and the tiles always sat up to four in a row, so a setup with two printers got two small tiles side by side.
+  - **On the Printers page:** while the Cam Wall is showing, the selector sizes the tiles: S fits up to five per row on a wide screen, M four (as before, and still the default), L two, and XL one tile across the full width. Tiles keep their 16:9 shape.
+  - **Remembered on its own:** the Cam Wall keeps its own size, so changing it leaves the card size alone, and the other way round.
+  - **On `/camwall`:** the standalone page uses the size last picked on the Printers page, and a kiosk URL can set it with `size=s`, `m`, `l` or `xl`.
 - **Printers can have a wear cost per printing hour, added to what each print costs (#694, requested by @wreuel)** — Print costs covered filament and energy, but not the printer itself: nozzles, belts, the hotend and eventually the machine. Print farms and anyone selling prints usually add an hourly amount for that.
 - **Printers can have a wear cost per printing hour, added to what each print costs (#694, requested by @wreuel)** — Print costs covered filament and energy, but not the printer itself: nozzles, belts, the hotend and eventually the machine. Print farms and anyone selling prints usually add an hourly amount for that.
   - **Setting it:** **Edit printer** has a new optional field, **Wear cost per printing hour**, for example 0.20 for a 600 printer you expect to last 3,000 hours. Empty or 0 leaves it off, so nothing changes until you set one.
   - **Setting it:** **Edit printer** has a new optional field, **Wear cost per printing hour**, for example 0.20 for a 600 printer you expect to last 3,000 hours. Empty or 0 leaves it off, so nothing changes until you set one.
   - **How it's costed:** each print's duration as shown in the Print Log, times the printer's rate. Failed and cancelled prints count, because they wore the printer too. A print closed after Bambuddy was offline has no known duration and gets none. The rate applies from the next print on and never rewrites earlier ones.
   - **How it's costed:** each print's duration as shown in the Print Log, times the printer's rate. Failed and cancelled prints count, because they wore the printer too. A print closed after Bambuddy was offline has no known duration and gets none. The rate applies from the next print on and never rewrites earlier ones.

+ 34 - 0
frontend/src/__tests__/pages/CamWallPage.test.tsx

@@ -143,3 +143,37 @@ describe('CamWallPage — signed out, no token', () => {
     expect(api.getCamWallPrinters).not.toHaveBeenCalled();
     expect(api.getCamWallPrinters).not.toHaveBeenCalled();
   });
   });
 });
 });
+
+describe('CamWallPage — tile size (#2735)', () => {
+  beforeEach(() => {
+    vi.spyOn(api, 'getCamWallPrinters').mockResolvedValue(FEED);
+    vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 401 }));
+    localStorage.clear();
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  const grid = async () => {
+    await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument());
+    return screen.getByTestId('camwall-grid').className;
+  };
+
+  it('takes ?size=xl for a single full-width column', async () => {
+    renderAt(`?token=${KIOSK_TOKEN}&size=xl`);
+    const classes = await grid();
+    expect(classes).toContain('grid-cols-1');
+    expect(classes).not.toContain('sm:grid-cols-2');
+  });
+
+  it('takes ?size=s for the most tiles per row', async () => {
+    renderAt(`?token=${KIOSK_TOKEN}&size=S`);
+    expect(await grid()).toContain('xl:grid-cols-5');
+  });
+
+  it('falls back to the medium grid for a size it does not know', async () => {
+    renderAt(`?token=${KIOSK_TOKEN}&size=huge`);
+    expect(await grid()).toContain('xl:grid-cols-4');
+  });
+});

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

@@ -0,0 +1,117 @@
+/**
+ * Tile size on the Cam Wall (#2735).
+ *
+ * The S/M/L/XL control used to grey out as soon as the page switched to the
+ * Cam Wall, so a two-printer setup was stuck with small tiles side by side. It
+ * now sizes the tiles there, and keeps that choice apart from the card size:
+ * S on the cards also means the compact card layout.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const printer = {
+  id: 1,
+  name: 'A1-002',
+  serial_number: '03919C111111111',
+  model: 'A1',
+  ip_address: '192.168.1.100',
+  access_code: '12345678',
+  enabled: true,
+  is_active: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'hardened_steel',
+  auto_archive: true,
+  camera_rotation: 0,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const status = {
+  connected: true,
+  state: 'IDLE',
+  awaiting_plate_clear: false,
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+  remaining_time: 0,
+  filename: null,
+  wifi_signal: -50,
+  vt_tray: [],
+};
+
+// The shared setup's localStorage stores nothing; this one holds what it is given.
+const store = new Map<string, string>();
+
+beforeEach(() => {
+  store.clear();
+  vi.mocked(localStorage.getItem).mockImplementation((key: string) => store.get(key) ?? null);
+  vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => {
+    store.set(key, String(value));
+  });
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json([printer])),
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)),
+    http.get('/api/v1/settings/ui-preferences', () => HttpResponse.json({})),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+  );
+});
+
+afterEach(() => {
+  store.clear();
+  vi.mocked(localStorage.getItem).mockReset();
+  vi.mocked(localStorage.setItem).mockReset();
+});
+
+const grid = () => screen.getByTestId('camwall-grid');
+
+describe('Cam Wall tile size', () => {
+  it('sizes the tiles on the Cam Wall', async () => {
+    store.set('printerPageView', 'camwall');
+    render(<PrintersPage />);
+    await waitFor(() => expect(grid()).toBeInTheDocument());
+    expect(grid().className).toContain('xl:grid-cols-4');
+
+    await userEvent.setup().click(screen.getAllByTitle('Extra large tiles')[0]);
+
+    expect(grid().className).toContain('grid-cols-1');
+    expect(grid().className).not.toContain('sm:grid-cols-2');
+    expect(store.get('camWallTileSize')).toBe('4');
+  });
+
+  it('leaves the card size alone', async () => {
+    store.set('printerPageView', 'camwall');
+    store.set('printerCardSize', '2');
+    render(<PrintersPage />);
+    await waitFor(() => expect(grid()).toBeInTheDocument());
+
+    await userEvent.setup().click(screen.getAllByTitle('Small tiles')[0]);
+
+    expect(store.get('camWallTileSize')).toBe('1');
+    expect(store.get('printerCardSize')).toBe('2');
+  });
+
+  it('opens with the size picked last time', async () => {
+    store.set('printerPageView', 'camwall');
+    store.set('camWallTileSize', '3');
+    render(<PrintersPage />);
+    await waitFor(() => expect(grid()).toBeInTheDocument());
+
+    expect(grid().className).toContain('lg:grid-cols-2');
+    expect(screen.getAllByTitle('Large tiles')[0].className).toContain('bg-bambu-green');
+  });
+
+  it('still sizes the cards in Cards mode', async () => {
+    render(<PrintersPage />);
+    await screen.findByText('A1-002');
+
+    expect(screen.getAllByTitle('Large cards')[0]).toBeInTheDocument();
+    expect(screen.queryByTitle('Large tiles')).not.toBeInTheDocument();
+  });
+});

+ 6 - 1
frontend/src/components/CameraWall.tsx

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useQueries } from '@tanstack/react-query';
 import { useQueries } from '@tanstack/react-query';
 import { Settings as SettingsIcon } from 'lucide-react';
 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 { CameraTile, type CameraTileMode, type CameraTileStatusMode } from './CameraTile';
 import { filterKnownHMSErrors } from './HMSErrorModal';
 import { filterKnownHMSErrors } from './HMSErrorModal';
 import { api, type PrinterStatus } from '../api/client';
 import { api, type PrinterStatus } from '../api/client';
@@ -49,6 +50,9 @@ interface CameraWallProps {
   // Kiosk walls hide the settings popover — the knobs come from the URL, and
   // Kiosk walls hide the settings popover — the knobs come from the URL, and
   // there is nobody standing at the screen to turn them.
   // there is nobody standing at the screen to turn them.
   showSettings?: boolean;
   showSettings?: boolean;
+  // 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;
 }
 }
 
 
 const MIN_MAX_LIVE = 1;
 const MIN_MAX_LIVE = 1;
@@ -68,6 +72,7 @@ export function CameraWall({
   onChangeStatusMode,
   onChangeStatusMode,
   statuses,
   statuses,
   showSettings: settingsEnabled = true,
   showSettings: settingsEnabled = true,
+  tileSize = DEFAULT_CAM_WALL_TILE_SIZE,
 }: CameraWallProps) {
 }: CameraWallProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const tileRefs = useRef<Map<number, HTMLDivElement | null>>(new Map());
   const tileRefs = useRef<Map<number, HTMLDivElement | null>>(new Map());
@@ -271,7 +276,7 @@ export function CameraWall({
         )}
         )}
       </div>
       </div>
 
 
-      <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
+      <div className={`grid gap-3 ${camWallGridClasses(tileSize)}`} data-testid="camwall-grid">
         {printers.map((p) => {
         {printers.map((p) => {
           const mode = modeByPrinter.get(p.id) ?? 'paused';
           const mode = modeByPrinter.get(p.id) ?? 'paused';
           return (
           return (

+ 13 - 0
frontend/src/components/camWallTileSize.ts

@@ -0,0 +1,13 @@
+// Cam Wall tile sizes (#2735): 1=S, 2=M, 3=L, 4=XL, the same scale as the
+// printer cards. The size sets how many tiles share a row; tiles stay 16:9.
+export const CAM_WALL_TILE_SIZES = ['s', 'm', 'l', 'xl'] as const;
+export const DEFAULT_CAM_WALL_TILE_SIZE = 2;
+
+export function camWallGridClasses(tileSize: number): string {
+  switch (tileSize) {
+    case 1: return 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5';
+    case 3: return 'grid-cols-1 lg:grid-cols-2';
+    case 4: return 'grid-cols-1';
+    default: return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4';
+  }
+}

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

@@ -361,6 +361,12 @@ export default {
           'Dieser Kamera-Wand-Link ist nicht mehr gültig. Das Token ist möglicherweise abgelaufen oder wurde widerrufen.',
           'Dieser Kamera-Wand-Link ist nicht mehr gültig. Das Token ist möglicherweise abgelaufen oder wurde widerrufen.',
         loadFailed: 'Die Drucker konnten nicht geladen werden.',
         loadFailed: 'Die Drucker konnten nicht geladen werden.',
       },
       },
+      tileSize: {
+        small: 'Kleine Kacheln',
+        medium: 'Mittlere Kacheln',
+        large: 'Große Kacheln',
+        extraLarge: 'Extra große Kacheln',
+      },
       noPrinters: 'Keine Drucker anzuzeigen',
       noPrinters: 'Keine Drucker anzuzeigen',
       noSignal: 'Kein Signal',
       noSignal: 'Kein Signal',
       live: 'Live',
       live: 'Live',

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

@@ -363,6 +363,12 @@ export default {
           'This Cam Wall link is no longer valid. The token may have expired or been revoked.',
           'This Cam Wall link is no longer valid. The token may have expired or been revoked.',
         loadFailed: 'Could not load the printers.',
         loadFailed: 'Could not load the printers.',
       },
       },
+      tileSize: {
+        small: 'Small tiles',
+        medium: 'Medium tiles',
+        large: 'Large tiles',
+        extraLarge: 'Extra large tiles',
+      },
       noPrinters: 'No printers to show',
       noPrinters: 'No printers to show',
       noSignal: 'No signal',
       noSignal: 'No signal',
       live: 'Live',
       live: 'Live',

+ 6 - 0
frontend/src/i18n/locales/es.ts

@@ -361,6 +361,12 @@ export default {
           'Este enlace del muro de cámaras ya no es válido. Es posible que el token haya caducado o se haya revocado.',
           'Este enlace del muro de cámaras ya no es válido. Es posible que el token haya caducado o se haya revocado.',
         loadFailed: 'No se han podido cargar las impresoras.',
         loadFailed: 'No se han podido cargar las impresoras.',
       },
       },
+      tileSize: {
+        small: 'Mosaicos pequeños',
+        medium: 'Mosaicos medianos',
+        large: 'Mosaicos grandes',
+        extraLarge: 'Mosaicos extragrandes',
+      },
       noPrinters: 'No hay impresoras que mostrar',
       noPrinters: 'No hay impresoras que mostrar',
       noSignal: 'Sin señal',
       noSignal: 'Sin señal',
       live: 'En vivo',
       live: 'En vivo',

+ 6 - 0
frontend/src/i18n/locales/fr.ts

@@ -361,6 +361,12 @@ export default {
           "Ce lien du mur de caméras n'est plus valide. Le jeton a peut-être expiré ou été révoqué.",
           "Ce lien du mur de caméras n'est plus valide. Le jeton a peut-être expiré ou été révoqué.",
         loadFailed: 'Impossible de charger les imprimantes.',
         loadFailed: 'Impossible de charger les imprimantes.',
       },
       },
+      tileSize: {
+        small: 'Petites vignettes',
+        medium: 'Vignettes moyennes',
+        large: 'Grandes vignettes',
+        extraLarge: 'Très grandes vignettes',
+      },
       noPrinters: 'Aucune imprimante à afficher',
       noPrinters: 'Aucune imprimante à afficher',
       noSignal: 'Aucun signal',
       noSignal: 'Aucun signal',
       live: 'En direct',
       live: 'En direct',

+ 6 - 0
frontend/src/i18n/locales/it.ts

@@ -361,6 +361,12 @@ export default {
           'Questo link al muro telecamere non è più valido. Il token potrebbe essere scaduto o essere stato revocato.',
           'Questo link al muro telecamere non è più valido. Il token potrebbe essere scaduto o essere stato revocato.',
         loadFailed: 'Impossibile caricare le stampanti.',
         loadFailed: 'Impossibile caricare le stampanti.',
       },
       },
+      tileSize: {
+        small: 'Riquadri piccoli',
+        medium: 'Riquadri medi',
+        large: 'Riquadri grandi',
+        extraLarge: 'Riquadri extra grandi',
+      },
       noPrinters: 'Nessuna stampante da mostrare',
       noPrinters: 'Nessuna stampante da mostrare',
       noSignal: 'Nessun segnale',
       noSignal: 'Nessun segnale',
       live: 'Live',
       live: 'Live',

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

@@ -360,6 +360,12 @@ export default {
           'このカメラウォールのリンクは無効です。トークンの有効期限が切れたか、取り消された可能性があります。',
           'このカメラウォールのリンクは無効です。トークンの有効期限が切れたか、取り消された可能性があります。',
         loadFailed: 'プリンターを読み込めませんでした。',
         loadFailed: 'プリンターを読み込めませんでした。',
       },
       },
+      tileSize: {
+        small: '小タイル',
+        medium: '中タイル',
+        large: '大タイル',
+        extraLarge: '特大タイル',
+      },
       noPrinters: '表示するプリンターがありません',
       noPrinters: '表示するプリンターがありません',
       noSignal: '信号なし',
       noSignal: '信号なし',
       live: 'ライブ',
       live: 'ライブ',

+ 6 - 0
frontend/src/i18n/locales/ko.ts

@@ -347,6 +347,12 @@ export default {
           '이 카메라 월 링크는 더 이상 유효하지 않습니다. 토큰이 만료되었거나 취소되었을 수 있습니다.',
           '이 카메라 월 링크는 더 이상 유효하지 않습니다. 토큰이 만료되었거나 취소되었을 수 있습니다.',
         loadFailed: '프린터를 불러오지 못했습니다.',
         loadFailed: '프린터를 불러오지 못했습니다.',
       },
       },
+      tileSize: {
+        small: '작은 타일',
+        medium: '중간 타일',
+        large: '큰 타일',
+        extraLarge: '아주 큰 타일',
+      },
       noPrinters: '표시할 프린터가 없습니다',
       noPrinters: '표시할 프린터가 없습니다',
       noSignal: '신호 없음',
       noSignal: '신호 없음',
       live: '라이브',
       live: '라이브',

+ 6 - 0
frontend/src/i18n/locales/nl.ts

@@ -361,6 +361,12 @@ export default {
           'Deze camerawandlink is niet meer geldig. Het token is mogelijk verlopen of ingetrokken.',
           'Deze camerawandlink is niet meer geldig. Het token is mogelijk verlopen of ingetrokken.',
         loadFailed: 'Printers konden niet worden geladen.',
         loadFailed: 'Printers konden niet worden geladen.',
       },
       },
+      tileSize: {
+        small: 'Kleine tegels',
+        medium: 'Middelgrote tegels',
+        large: 'Grote tegels',
+        extraLarge: 'Extra grote tegels',
+      },
       noPrinters: 'Geen printers om weer te geven',
       noPrinters: 'Geen printers om weer te geven',
       noSignal: 'Geen signaal',
       noSignal: 'Geen signaal',
       live: 'Live',
       live: 'Live',

+ 6 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -361,6 +361,12 @@ export default {
           'Este link do mural de câmeras não é mais válido. O token pode ter expirado ou sido revogado.',
           'Este link do mural de câmeras não é mais válido. O token pode ter expirado ou sido revogado.',
         loadFailed: 'Não foi possível carregar as impressoras.',
         loadFailed: 'Não foi possível carregar as impressoras.',
       },
       },
+      tileSize: {
+        small: 'Blocos pequenos',
+        medium: 'Blocos médios',
+        large: 'Blocos grandes',
+        extraLarge: 'Blocos extra grandes',
+      },
       noPrinters: 'Nenhuma impressora para exibir',
       noPrinters: 'Nenhuma impressora para exibir',
       noSignal: 'Sem sinal',
       noSignal: 'Sem sinal',
       live: 'Ao vivo',
       live: 'Ao vivo',

+ 6 - 0
frontend/src/i18n/locales/ru.ts

@@ -352,6 +352,12 @@ export default {
         tokenRejected: "Эта ссылка на стену камер больше недействительна. Возможно, срок действия токена истёк или он был отозван.",
         tokenRejected: "Эта ссылка на стену камер больше недействительна. Возможно, срок действия токена истёк или он был отозван.",
         loadFailed: "Не удалось загрузить принтеры.",
         loadFailed: "Не удалось загрузить принтеры.",
       },
       },
+      tileSize: {
+        small: "Маленькие плитки",
+        medium: "Средние плитки",
+        large: "Большие плитки",
+        extraLarge: "Очень большие плитки",
+      },
       noPrinters: "Нет принтеров для отображения",
       noPrinters: "Нет принтеров для отображения",
       noSignal: "Нет сигнала",
       noSignal: "Нет сигнала",
       live: "Онлайн",
       live: "Онлайн",

+ 6 - 0
frontend/src/i18n/locales/sv.ts

@@ -361,6 +361,12 @@ export default {
           'Denna länk till kameraväggen är inte längre giltig. Token kan ha löpt ut eller återkallats.',
           'Denna länk till kameraväggen är inte längre giltig. Token kan ha löpt ut eller återkallats.',
         loadFailed: 'Kunde inte ladda skrivarna.',
         loadFailed: 'Kunde inte ladda skrivarna.',
       },
       },
+      tileSize: {
+        small: 'Små rutor',
+        medium: 'Mellanstora rutor',
+        large: 'Stora rutor',
+        extraLarge: 'Extra stora rutor',
+      },
       noPrinters: 'Inga skrivare att visa',
       noPrinters: 'Inga skrivare att visa',
       noSignal: 'Ingen signal',
       noSignal: 'Ingen signal',
       live: 'Live',
       live: 'Live',

+ 6 - 0
frontend/src/i18n/locales/tr.ts

@@ -361,6 +361,12 @@ export default {
           'Bu kamera duvarı bağlantısı artık geçerli değil. Belirtecin süresi dolmuş veya belirteç iptal edilmiş olabilir.',
           'Bu kamera duvarı bağlantısı artık geçerli değil. Belirtecin süresi dolmuş veya belirteç iptal edilmiş olabilir.',
         loadFailed: 'Yazıcılar yüklenemedi.',
         loadFailed: 'Yazıcılar yüklenemedi.',
       },
       },
+      tileSize: {
+        small: 'Küçük kutucuklar',
+        medium: 'Orta kutucuklar',
+        large: 'Büyük kutucuklar',
+        extraLarge: 'Çok büyük kutucuklar',
+      },
       noPrinters: 'Gösterilecek yazıcı yok',
       noPrinters: 'Gösterilecek yazıcı yok',
       noSignal: 'Sinyal yok',
       noSignal: 'Sinyal yok',
       live: 'Canlı',
       live: 'Canlı',

+ 6 - 0
frontend/src/i18n/locales/uk.ts

@@ -361,6 +361,12 @@ export default {
           "Це посилання на панель камер більше не дійсне. Можливо, термін дії токена минув або токен було відкликано.",
           "Це посилання на панель камер більше не дійсне. Можливо, термін дії токена минув або токен було відкликано.",
         loadFailed: "Не вдалося завантажити список принтерів.",
         loadFailed: "Не вдалося завантажити список принтерів.",
       },
       },
+      tileSize: {
+        small: "Маленькі плитки",
+        medium: "Середні плитки",
+        large: "Великі плитки",
+        extraLarge: "Дуже великі плитки",
+      },
       noPrinters: "Немає принтерів для показу",
       noPrinters: "Немає принтерів для показу",
       noSignal: "Немає сигналу",
       noSignal: "Немає сигналу",
       live: "Наживо",
       live: "Наживо",

+ 6 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -361,6 +361,12 @@ export default {
           '此摄像头墙链接已失效。令牌可能已过期或被撤销。',
           '此摄像头墙链接已失效。令牌可能已过期或被撤销。',
         loadFailed: '无法加载打印机。',
         loadFailed: '无法加载打印机。',
       },
       },
+      tileSize: {
+        small: '小图块',
+        medium: '中图块',
+        large: '大图块',
+        extraLarge: '超大图块',
+      },
       noPrinters: '没有可显示的打印机',
       noPrinters: '没有可显示的打印机',
       noSignal: '无信号',
       noSignal: '无信号',
       live: '直播',
       live: '直播',

+ 6 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -361,6 +361,12 @@ export default {
           '此攝影機牆連結已失效。權杖可能已過期或遭撤銷。',
           '此攝影機牆連結已失效。權杖可能已過期或遭撤銷。',
         loadFailed: '無法載入印表機。',
         loadFailed: '無法載入印表機。',
       },
       },
+      tileSize: {
+        small: '小圖塊',
+        medium: '中圖塊',
+        large: '大圖塊',
+        extraLarge: '超大圖塊',
+      },
       noPrinters: '沒有可顯示的印表機',
       noPrinters: '沒有可顯示的印表機',
       noSignal: '無訊號',
       noSignal: '無訊號',
       live: '直播',
       live: '直播',

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

@@ -24,6 +24,7 @@ import { Navigate, useLocation, useSearchParams } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { CameraWall, type CameraWallStatus } from '../components/CameraWall';
 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 { type CameraTileStatusMode } from '../components/CameraTile';
 import { api, setStreamToken } from '../api/client';
 import { api, setStreamToken } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
@@ -58,6 +59,15 @@ function fromUrlOrStorage(
   return clampInt(localStorage.getItem(storageKey), fallback, min, max);
   return clampInt(localStorage.getItem(storageKey), fallback, min, max);
 }
 }
 
 
+/** ``?size=s|m|l|xl`` (#2735), else the size last picked on the Printers page. */
+function tileSizeFrom(params: URLSearchParams): number {
+  const requested = params.get('size')?.toLowerCase();
+  const fromUrl = CAM_WALL_TILE_SIZES.indexOf(requested as (typeof CAM_WALL_TILE_SIZES)[number]);
+  if (fromUrl >= 0) return fromUrl + 1;
+  const saved = parseInt(localStorage.getItem('camWallTileSize') ?? '', 10);
+  return saved >= 1 && saved <= 4 ? saved : DEFAULT_CAM_WALL_TILE_SIZE;
+}
+
 export function CamWallPage() {
 export function CamWallPage() {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const location = useLocation();
   const location = useLocation();
@@ -83,6 +93,7 @@ export function CamWallPage() {
       MAX_SNAPSHOT_SEC,
       MAX_SNAPSHOT_SEC,
     ),
     ),
   );
   );
+  const [tileSize] = useState(() => tileSizeFrom(searchParams));
   const [statusMode, setStatusMode] = useState<CameraTileStatusMode>(() => {
   const [statusMode, setStatusMode] = useState<CameraTileStatusMode>(() => {
     const requested = searchParams.get('status') ?? localStorage.getItem('camWallStatusMode');
     const requested = searchParams.get('status') ?? localStorage.getItem('camWallStatusMode');
     // 'full' names the file on the bed. Fine on a signed-in screen, wrong on one
     // 'full' names the file on the bed. Fine on a signed-in screen, wrong on one
@@ -170,6 +181,7 @@ export function CamWallPage() {
         maxLive={maxLive}
         maxLive={maxLive}
         snapshotIntervalSec={snapshotIntervalSec}
         snapshotIntervalSec={snapshotIntervalSec}
         statusMode={statusMode}
         statusMode={statusMode}
+        tileSize={tileSize}
         statuses={kiosk ? kioskStatuses : undefined}
         statuses={kiosk ? kioskStatuses : undefined}
         showSettings={!kiosk}
         showSettings={!kiosk}
         onTileClick={kiosk ? undefined : (id) => window.open(`/camera/${id}`, `camera-${id}`)}
         onTileClick={kiosk ? undefined : (id) => window.open(`/camera/${id}`, `camera-${id}`)}

+ 16 - 3
frontend/src/pages/PrintersPage.tsx

@@ -173,6 +173,7 @@ import { BulkPrinterToolbar, type PrinterState } from '../components/BulkPrinter
 import { FileManagerModal } from '../components/FileManagerModal';
 import { FileManagerModal } from '../components/FileManagerModal';
 import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
 import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
 import { CameraWall } from '../components/CameraWall';
 import { CameraWall } from '../components/CameraWall';
+import { DEFAULT_CAM_WALL_TILE_SIZE } from '../components/camWallTileSize';
 import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal, filterKnownHMSErrors, isSevereHMSError } from '../components/HMSErrorModal';
 import { HMSErrorModal, filterKnownHMSErrors, isSevereHMSError } from '../components/HMSErrorModal';
@@ -8878,6 +8879,12 @@ export function PrintersPage() {
   // 'full' adds progress, layer, and time-left on printing/paused tiles.
   // 'full' adds progress, layer, and time-left on printing/paused tiles.
   // Defaulting to 'full' because the cards already show this info — users who
   // Defaulting to 'full' because the cards already show this info — users who
   // pick cam-wall view still want to glance the same details without flipping.
   // pick cam-wall view still want to glance the same details without flipping.
+  // Kept apart from the card size: S on the cards also means the compact
+  // layout, and a wall of 2 cameras wants big tiles next to small cards (#2735).
+  const [camWallTileSize, setCamWallTileSize] = useState<number>(() => {
+    const saved = parseInt(localStorage.getItem('camWallTileSize') || '', 10);
+    return saved >= 1 && saved <= 4 ? saved : DEFAULT_CAM_WALL_TILE_SIZE;
+  });
   const [camWallStatusMode, setCamWallStatusMode] = useState<'off' | 'compact' | 'full'>(() => {
   const [camWallStatusMode, setCamWallStatusMode] = useState<'off' | 'compact' | 'full'>(() => {
     const saved = localStorage.getItem('camWallStatusMode');
     const saved = localStorage.getItem('camWallStatusMode');
     return saved === 'off' || saved === 'compact' || saved === 'full' ? saved : 'full';
     return saved === 'off' || saved === 'compact' || saved === 'full' ? saved : 'full';
@@ -9733,14 +9740,19 @@ export function PrintersPage() {
       )}
       )}
 
 
       {/* Card size selector */}
       {/* Card size selector */}
-      <div className={`flex h-8 items-center bg-bambu-dark rounded-lg border border-bambu-dark-tertiary ${pageView === 'camwall' ? 'opacity-40 pointer-events-none' : ''} ${inMenu ? 'w-full' : ''}`}>
+      <div className={`flex h-8 items-center bg-bambu-dark rounded-lg border border-bambu-dark-tertiary ${inMenu ? 'w-full' : ''}`}>
         {cardSizeLabels.map((label, index) => {
         {cardSizeLabels.map((label, index) => {
           const size = index + 1;
           const size = index + 1;
-          const isSelected = cardSize === size;
+          const isSelected = (pageView === 'camwall' ? camWallTileSize : cardSize) === size;
           return (
           return (
             <button
             <button
               key={label}
               key={label}
               onClick={() => {
               onClick={() => {
+                if (pageView === 'camwall') {
+                  setCamWallTileSize(size);
+                  localStorage.setItem('camWallTileSize', String(size));
+                  return;
+                }
                 setCompactDrilldownPrinterId(null);
                 setCompactDrilldownPrinterId(null);
                 setCardSize(size);
                 setCardSize(size);
                 localStorage.setItem('printerCardSize', String(size));
                 localStorage.setItem('printerCardSize', String(size));
@@ -9754,7 +9766,7 @@ export function PrintersPage() {
                   ? 'bg-bambu-green text-white'
                   ? 'bg-bambu-green text-white'
                   : 'text-white hover:bg-bambu-dark-tertiary'
                   : 'text-white hover:bg-bambu-dark-tertiary'
               }`}
               }`}
-              title={label === 'S' ? t('printers.cardSize.small') : label === 'M' ? t('printers.cardSize.medium') : label === 'L' ? t('printers.cardSize.large') : t('printers.cardSize.extraLarge')}
+              title={t(`printers.${pageView === 'camwall' ? 'camWall.tileSize' : 'cardSize'}.${['small', 'medium', 'large', 'extraLarge'][index]}`)}
             >
             >
               {label}
               {label}
             </button>
             </button>
@@ -9942,6 +9954,7 @@ export function PrintersPage() {
           printers={sortedPrinters}
           printers={sortedPrinters}
           maxLive={camWallMaxLive}
           maxLive={camWallMaxLive}
           snapshotIntervalSec={camWallSnapshotSec}
           snapshotIntervalSec={camWallSnapshotSec}
+          tileSize={camWallTileSize}
           onTileClick={(id, name) => {
           onTileClick={(id, name) => {
             // A wall tile has no room for a split button, so it follows the
             // A wall tile has no room for a split button, so it follows the
             // mode the card buttons last chose.
             // mode the card buttons last chose.

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ImagePreviewModal-Ur6Q-8rB.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/PdfPreviewModal-CmFyOgBV.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpreadsheetPreviewModal-BSxmGqKm.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
static/assets/index-Da4YLBSD.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/pdf-C_z1bvlY.js


+ 1 - 1
static/index.html

@@ -26,7 +26,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-DntsHhWu.js"></script>
+    <script type="module" crossorigin src="/assets/index-Da4YLBSD.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-Bb4TsUSQ.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Bb4TsUSQ.css">
   </head>
   </head>

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