Browse Source

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 days ago
parent
commit
0054875d18

+ 4 - 0
CHANGELOG.md

@@ -5,6 +5,10 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### 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.
   - **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.

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

@@ -143,3 +143,37 @@ describe('CamWallPage — signed out, no token', () => {
     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 { 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 { filterKnownHMSErrors } from './HMSErrorModal';
 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
   // there is nobody standing at the screen to turn them.
   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;
@@ -68,6 +72,7 @@ export function CameraWall({
   onChangeStatusMode,
   statuses,
   showSettings: settingsEnabled = true,
+  tileSize = DEFAULT_CAM_WALL_TILE_SIZE,
 }: CameraWallProps) {
   const { t } = useTranslation();
   const tileRefs = useRef<Map<number, HTMLDivElement | null>>(new Map());
@@ -271,7 +276,7 @@ export function CameraWall({
         )}
       </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) => {
           const mode = modeByPrinter.get(p.id) ?? 'paused';
           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.',
         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',
       noSignal: 'Kein Signal',
       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.',
         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',
       noSignal: 'No signal',
       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.',
         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',
       noSignal: 'Sin señal',
       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é.",
         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',
       noSignal: 'Aucun signal',
       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.',
         loadFailed: 'Impossibile caricare le stampanti.',
       },
+      tileSize: {
+        small: 'Riquadri piccoli',
+        medium: 'Riquadri medi',
+        large: 'Riquadri grandi',
+        extraLarge: 'Riquadri extra grandi',
+      },
       noPrinters: 'Nessuna stampante da mostrare',
       noSignal: 'Nessun segnale',
       live: 'Live',

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

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

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

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

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

@@ -352,6 +352,12 @@ export default {
         tokenRejected: "Эта ссылка на стену камер больше недействительна. Возможно, срок действия токена истёк или он был отозван.",
         loadFailed: "Не удалось загрузить принтеры.",
       },
+      tileSize: {
+        small: "Маленькие плитки",
+        medium: "Средние плитки",
+        large: "Большие плитки",
+        extraLarge: "Очень большие плитки",
+      },
       noPrinters: "Нет принтеров для отображения",
       noSignal: "Нет сигнала",
       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.',
         loadFailed: 'Kunde inte ladda skrivarna.',
       },
+      tileSize: {
+        small: 'Små rutor',
+        medium: 'Mellanstora rutor',
+        large: 'Stora rutor',
+        extraLarge: 'Extra stora rutor',
+      },
       noPrinters: 'Inga skrivare att visa',
       noSignal: 'Ingen signal',
       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.',
         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',
       noSignal: 'Sinyal yok',
       live: 'Canlı',

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

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

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

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

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

@@ -361,6 +361,12 @@ export default {
           '此攝影機牆連結已失效。權杖可能已過期或遭撤銷。',
         loadFailed: '無法載入印表機。',
       },
+      tileSize: {
+        small: '小圖塊',
+        medium: '中圖塊',
+        large: '大圖塊',
+        extraLarge: '超大圖塊',
+      },
       noPrinters: '沒有可顯示的印表機',
       noSignal: '無訊號',
       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 { 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 { api, setStreamToken } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
@@ -58,6 +59,15 @@ function fromUrlOrStorage(
   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() {
   const { t } = useTranslation();
   const location = useLocation();
@@ -83,6 +93,7 @@ export function CamWallPage() {
       MAX_SNAPSHOT_SEC,
     ),
   );
+  const [tileSize] = useState(() => tileSizeFrom(searchParams));
   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
@@ -170,6 +181,7 @@ export function CamWallPage() {
         maxLive={maxLive}
         snapshotIntervalSec={snapshotIntervalSec}
         statusMode={statusMode}
+        tileSize={tileSize}
         statuses={kiosk ? kioskStatuses : undefined}
         showSettings={!kiosk}
         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 { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
 import { CameraWall } from '../components/CameraWall';
+import { DEFAULT_CAM_WALL_TILE_SIZE } from '../components/camWallTileSize';
 import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 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.
   // 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.
+  // 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 saved = localStorage.getItem('camWallStatusMode');
     return saved === 'off' || saved === 'compact' || saved === 'full' ? saved : 'full';
@@ -9733,14 +9740,19 @@ export function PrintersPage() {
       )}
 
       {/* 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) => {
           const size = index + 1;
-          const isSelected = cardSize === size;
+          const isSelected = (pageView === 'camwall' ? camWallTileSize : cardSize) === size;
           return (
             <button
               key={label}
               onClick={() => {
+                if (pageView === 'camwall') {
+                  setCamWallTileSize(size);
+                  localStorage.setItem('camWallTileSize', String(size));
+                  return;
+                }
                 setCompactDrilldownPrinterId(null);
                 setCardSize(size);
                 localStorage.setItem('printerCardSize', String(size));
@@ -9754,7 +9766,7 @@ export function PrintersPage() {
                   ? 'bg-bambu-green text-white'
                   : '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}
             </button>
@@ -9942,6 +9954,7 @@ export function PrintersPage() {
           printers={sortedPrinters}
           maxLive={camWallMaxLive}
           snapshotIntervalSec={camWallSnapshotSec}
+          tileSize={camWallTileSize}
           onTileClick={(id, name) => {
             // A wall tile has no room for a split button, so it follows the
             // mode the card buttons last chose.

File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-Ur6Q-8rB.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-CmFyOgBV.js


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


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


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


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

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