Przeglądaj źródła

Let the external spool be hidden from the printer card (#1782)

An external spool holder that never gets used still takes a full card's
width in the Filaments row, next to the AMS units that are actually in
use. An eye icon at the right-hand end of that row's header now hides
it, and clicking it again brings it back -- the affordance stays in
place rather than moving to a settings page, so the choice is
discoverable and reversible where it applies.

Per printer rather than global. A global flag would suit a toolbar
button, but an icon on the card that silently rearranged every other
card would surprise; it is keyed by printer id in one localStorage
entry, the same shape as printerCollapsedSections, and sits alongside
the other browser-local printer-page view preferences.

The toggle is offered only when the printer has at least one AMS. On a
machine with no AMS the external spool is the entire filament section,
so hiding it would leave an empty row with no control to undo it. The
icon and the hide condition read the same canHideExternalSpool, so a
preference stored before an AMS was unplugged cannot blank the row
either -- the spool reappears instead.

The store lives in a new utils/printerCardPrefs.ts rather than in the
9,157-line page. It re-reads before writing so two cards toggled in one
session cannot clobber each other's entry, deletes the key instead of
storing false, and treats a malformed or unavailable localStorage as
"nothing hidden" so a private-mode browser cannot throw out of a render.
maziggy 1 miesiąc temu
rodzic
commit
3db8ac9da7

Plik diff jest za duży
+ 0 - 0
CHANGELOG.md


+ 181 - 0
frontend/src/__tests__/pages/PrintersPageExternalSpoolToggle.test.tsx

@@ -0,0 +1,181 @@
+/**
+ * Hiding the external spool from the printer card (#1782, reporter @Arn0uDz).
+ *
+ * The toggle lives in the filament section header next to the AMS Backup
+ * badge. It is offered only when an AMS is present: on a printer that feeds
+ * from the external spool alone, the external spool IS the filament section,
+ * so hiding it would leave an empty row and no way to see the loaded filament.
+ */
+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 STORE_KEY = 'printerHiddenExternalSpools';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1C',
+  ip_address: '192.168.1.100',
+  serial_number: '01P00A000000001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'stainless_steel',
+  location: 'Workshop',
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const baseTray = {
+  tray_color: 'FF0000FF',
+  tray_type: 'PLA',
+  tray_sub_brands: 'PLA Basic',
+  tray_id_name: 'A00-R0',
+  tray_info_idx: 'GFA00',
+  remain: 80,
+  k: 0.02,
+  cali_idx: null,
+  tag_uid: null,
+  tray_uuid: null,
+  nozzle_temp_min: 190,
+  nozzle_temp_max: 230,
+  drying_temp: null,
+  drying_time: null,
+  state: 3,
+};
+
+const amsUnit = {
+  id: 0,
+  humidity: 30,
+  temp: 33,
+  is_ams_ht: false,
+  serial_number: 'AMS00',
+  sw_ver: '03.00.21.29',
+  dry_time: 0,
+  dry_status: 0,
+  dry_sub_status: 0,
+  dry_sf_reason: [],
+  module_type: 'n3f',
+  tray: [0, 1, 2, 3].map((id) => ({ id, ...baseTray })),
+};
+
+function makeStatus({ withAms }: { withAms: boolean }) {
+  return {
+    connected: true,
+    state: 'IDLE',
+    progress: 0,
+    layer_num: 0,
+    total_layers: 0,
+    temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+    remaining_time: 0,
+    filename: null,
+    wifi_signal: -29,
+    speed_level: 2,
+    supports_drying: true,
+    drying_screen_only: false,
+    ams: withAms ? [amsUnit] : [],
+    vt_tray: [{ id: 254, ...baseTray, tray_type: 'PETG', tray_sub_brands: 'PETG HF' }],
+  };
+}
+
+const WITH_AMS = makeStatus({ withAms: true });
+const WITHOUT_AMS = makeStatus({ withAms: false });
+
+const HIDE_TITLE = 'Hide external spool';
+const SHOW_TITLE = 'Show external spool';
+
+/** The external spool's own card is labelled with `printers.external`. */
+function externalSpoolCards() {
+  return screen.queryAllByText('External');
+}
+
+let store: Record<string, string>;
+
+describe('PrintersPage — hide the external spool (#1782)', () => {
+  beforeEach(() => {
+    store = {};
+    vi.mocked(localStorage.getItem).mockImplementation((key: string) => store[key] ?? null);
+    vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => {
+      store[key] = String(value);
+    });
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+      http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    );
+  });
+
+  afterEach(() => {
+    vi.mocked(localStorage.getItem).mockReset();
+    vi.mocked(localStorage.setItem).mockReset();
+  });
+
+  it('hides the external spool when the toggle is clicked, and brings it back', async () => {
+    const user = userEvent.setup();
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(WITH_AMS)));
+
+    render(<PrintersPage />);
+
+    const toggle = await screen.findByTitle(HIDE_TITLE);
+    expect(externalSpoolCards().length).toBeGreaterThan(0);
+
+    await user.click(toggle);
+    await waitFor(() => expect(externalSpoolCards()).toHaveLength(0));
+
+    // The toggle itself stays put — it is the only way back.
+    const restore = await screen.findByTitle(SHOW_TITLE);
+    await user.click(restore);
+    await waitFor(() => expect(externalSpoolCards().length).toBeGreaterThan(0));
+  });
+
+  it('persists the choice per printer', async () => {
+    const user = userEvent.setup();
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(WITH_AMS)));
+
+    render(<PrintersPage />);
+    await user.click(await screen.findByTitle(HIDE_TITLE));
+
+    // Keyed by printer id, so a second printer's card is untouched.
+    await waitFor(() => {
+      expect(JSON.parse(store[STORE_KEY])).toEqual({ '1': true });
+    });
+  });
+
+  it('starts hidden when the stored preference says so', async () => {
+    store[STORE_KEY] = JSON.stringify({ '1': true });
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(WITH_AMS)));
+
+    render(<PrintersPage />);
+
+    await screen.findByTitle(SHOW_TITLE);
+    expect(externalSpoolCards()).toHaveLength(0);
+  });
+
+  it('does not offer the toggle on a printer with no AMS', async () => {
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(WITHOUT_AMS)));
+
+    render(<PrintersPage />);
+
+    // The external spool is the whole filament section here, so it must stay.
+    await waitFor(() => expect(externalSpoolCards().length).toBeGreaterThan(0));
+    expect(screen.queryByTitle(HIDE_TITLE)).not.toBeInTheDocument();
+    expect(screen.queryByTitle(SHOW_TITLE)).not.toBeInTheDocument();
+  });
+
+  it('ignores a stored preference once the printer has no AMS left', async () => {
+    // The AMS was unplugged after the user hid the external spool. Honouring
+    // the stored flag would blank the filament row with no control to undo it.
+    store[STORE_KEY] = JSON.stringify({ '1': true });
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(WITHOUT_AMS)));
+
+    render(<PrintersPage />);
+
+    await waitFor(() => expect(externalSpoolCards().length).toBeGreaterThan(0));
+    expect(screen.queryByTitle(SHOW_TITLE)).not.toBeInTheDocument();
+  });
+});

+ 101 - 0
frontend/src/__tests__/utils/printerCardPrefs.test.ts

@@ -0,0 +1,101 @@
+/**
+ * Per-printer printer-card view preferences (#1782).
+ *
+ * The store is keyed by printer id so the toggle on one card cannot rearrange
+ * another, and it has to survive whatever is already sitting in localStorage —
+ * a value from an older format, or one another tab mangled — without throwing
+ * out of a render.
+ *
+ * The shared test setup stubs localStorage with bare vi.fn()s that store
+ * nothing, so this file backs them with a real in-memory object; a round-trip
+ * is the whole point of what's under test here.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import {
+  isExternalSpoolHidden,
+  setExternalSpoolHidden,
+} from '../../utils/printerCardPrefs';
+
+const KEY = 'printerHiddenExternalSpools';
+
+let store: Record<string, string>;
+
+function stored(): unknown {
+  return JSON.parse(store[KEY]);
+}
+
+describe('printerCardPrefs — external spool visibility', () => {
+  beforeEach(() => {
+    store = {};
+    vi.mocked(localStorage.getItem).mockImplementation((key: string) => store[key] ?? null);
+    vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => {
+      store[key] = String(value);
+    });
+  });
+
+  afterEach(() => {
+    vi.mocked(localStorage.getItem).mockReset();
+    vi.mocked(localStorage.setItem).mockReset();
+  });
+
+  it('defaults to visible for a printer that was never toggled', () => {
+    expect(isExternalSpoolHidden(1)).toBe(false);
+  });
+
+  it('round-trips the hidden flag through localStorage', () => {
+    setExternalSpoolHidden(7, true);
+    expect(isExternalSpoolHidden(7)).toBe(true);
+    expect(stored()).toEqual({ '7': true });
+  });
+
+  it('keeps each printer independent', () => {
+    setExternalSpoolHidden(1, true);
+    expect(isExternalSpoolHidden(1)).toBe(true);
+    expect(isExternalSpoolHidden(2)).toBe(false);
+
+    // Hiding a second printer must not disturb the first — the writer
+    // re-reads before merging rather than overwriting the whole object.
+    setExternalSpoolHidden(2, true);
+    expect(isExternalSpoolHidden(1)).toBe(true);
+    expect(isExternalSpoolHidden(2)).toBe(true);
+  });
+
+  it('drops the key when shown again rather than storing false', () => {
+    setExternalSpoolHidden(3, true);
+    setExternalSpoolHidden(3, false);
+
+    expect(isExternalSpoolHidden(3)).toBe(false);
+    // Otherwise the object grows an entry for every printer ever toggled twice.
+    expect(stored()).toEqual({});
+  });
+
+  it('treats malformed stored values as "nothing hidden"', () => {
+    for (const junk of ['not json', 'null', '"a string"', '[1,2,3]', '42']) {
+      store[KEY] = junk;
+      expect(isExternalSpoolHidden(1)).toBe(false);
+    }
+  });
+
+  it('recovers from a malformed store on the next write', () => {
+    store[KEY] = '[1,2,3]';
+    setExternalSpoolHidden(5, true);
+
+    expect(isExternalSpoolHidden(5)).toBe(true);
+    expect(stored()).toEqual({ '5': true });
+  });
+
+  it('survives localStorage being unavailable', () => {
+    vi.mocked(localStorage.getItem).mockImplementation(() => {
+      throw new Error('SecurityError: access denied');
+    });
+    vi.mocked(localStorage.setItem).mockImplementation(() => {
+      throw new Error('QuotaExceededError');
+    });
+
+    // Private-mode browsers throw on both. Neither may escape into a render.
+    expect(() => isExternalSpoolHidden(1)).not.toThrow();
+    expect(isExternalSpoolHidden(1)).toBe(false);
+    expect(() => setExternalSpoolHidden(1, true)).not.toThrow();
+  });
+});

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

@@ -618,6 +618,10 @@ export default {
     },
     // Filaments section
     filaments: 'Filamente',
+    externalSpool: {
+      hide: 'Externe Spule ausblenden',
+      show: 'Externe Spule einblenden',
+    },
     // Camera
     openCameraOverlay: 'Kamera-Overlay öffnen',
     openCameraWindow: 'Kamera in neuem Fenster öffnen',

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

@@ -622,6 +622,10 @@ export default {
     },
     // Filaments section
     filaments: 'Filaments',
+    externalSpool: {
+      hide: 'Hide external spool',
+      show: 'Show external spool',
+    },
     // Camera
     openCameraOverlay: 'Open camera overlay',
     openCameraWindow: 'Open camera in new window',

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

@@ -618,6 +618,10 @@ export default {
     },
     // Filaments section
     filaments: 'Filamentos',
+    externalSpool: {
+      hide: 'Ocultar bobina externa',
+      show: 'Mostrar bobina externa',
+    },
     // Camera
     openCameraOverlay: 'Abrir la cámara superpuesta',
     openCameraWindow: 'Abrir la cámara en una ventana nueva',

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

@@ -618,6 +618,10 @@ export default {
     },
     // Filaments section
     filaments: 'Filaments',
+    externalSpool: {
+      hide: 'Masquer la bobine externe',
+      show: 'Afficher la bobine externe',
+    },
     // Camera
     openCameraOverlay: 'Ouvrir la caméra en superposition',
     openCameraWindow: 'Ouvrir la caméra dans une fenêtre',

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

@@ -618,6 +618,10 @@ export default {
     },
     // Filaments section
     filaments: 'Filamenti',
+    externalSpool: {
+      hide: 'Nascondi bobina esterna',
+      show: 'Mostra bobina esterna',
+    },
     // Camera
     openCameraOverlay: 'Apri overlay camera',
     openCameraWindow: 'Apri camera in nuova finestra',

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

@@ -617,6 +617,10 @@ export default {
     },
     // Filaments section
     filaments: 'フィラメント',
+    externalSpool: {
+      hide: '外部スプールを非表示にする',
+      show: '外部スプールを表示する',
+    },
     // Camera
     openCameraOverlay: 'カメラオーバーレイを開く',
     openCameraWindow: 'カメラを新しいウィンドウで開く',

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

@@ -580,6 +580,10 @@ export default {
       external: '외부 스풀',
     },
     filaments: '필라멘트',
+    externalSpool: {
+      hide: '외부 스풀 숨기기',
+      show: '외부 스풀 표시',
+    },
     openCameraOverlay: '카메라 오버레이 열기',
     openCameraWindow: '새 창에서 카메라 열기',
     firmwareUpdateAvailable: '펌웨어 업데이트 가능: {{current}} → {{latest}}',

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

@@ -618,6 +618,10 @@ export default {
     },
     // Filaments section
     filaments: 'Filamentos',
+    externalSpool: {
+      hide: 'Ocultar bobina externa',
+      show: 'Mostrar bobina externa',
+    },
     // Camera
     openCameraOverlay: 'Abrir sobreposição da câmera',
     openCameraWindow: 'Abrir câmera em nova janela',

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

@@ -585,6 +585,10 @@ export default {
       external: "Внешняя катушка",
     },
     filaments: "Филаменты",
+    externalSpool: {
+      hide: "Скрыть внешнюю катушку",
+      show: "Показать внешнюю катушку",
+    },
     openCameraOverlay: "Открыть камеру поверх интерфейса",
     openCameraWindow: "Открыть камеру в новом окне",
     firmwareUpdateAvailable: "Доступно обновление прошивки: {{current}} → {{latest}}",

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

@@ -618,6 +618,10 @@ export default {
     },
     // Filamentler bölümü
     filaments: 'Filamentler',
+    externalSpool: {
+      hide: 'Harici makarayı gizle',
+      show: 'Harici makarayı göster',
+    },
     // Kamera
     openCameraOverlay: 'Kamera bindirmesini aç',
     openCameraWindow: 'Kamerayı yeni pencerede aç',

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

@@ -622,6 +622,10 @@ export default {
     },
     // Filaments section
     filaments: "Філаменти",
+    externalSpool: {
+      hide: "Сховати зовнішню котушку",
+      show: "Показати зовнішню котушку",
+    },
     // Camera
     openCameraOverlay: "Відкрити накладання камери",
     openCameraWindow: "Відкрити камеру в новому вікні",

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

@@ -618,6 +618,10 @@ export default {
     },
     // Filaments section
     filaments: '耗材',
+    externalSpool: {
+      hide: '隐藏外部料卷',
+      show: '显示外部料卷',
+    },
     // Camera
     openCameraOverlay: '打开摄像头叠加层',
     openCameraWindow: '在新窗口中打开摄像头',

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

@@ -618,6 +618,10 @@ export default {
     },
     // Filaments section
     filaments: '耗材',
+    externalSpool: {
+      hide: '隱藏外部料卷',
+      show: '顯示外部料卷',
+    },
     // Camera
     openCameraOverlay: '開啟攝影機疊加層',
     openCameraWindow: '在新視窗中開啟攝影機',

+ 69 - 1
frontend/src/pages/PrintersPage.tsx

@@ -3,6 +3,10 @@ import { createPortal } from 'react-dom';
 import { compareFwVersions } from '../utils/firmwareVersion';
 import { formatPrintName } from '../utils/printName';
 import { computePopoverPosition } from '../utils/popoverPosition';
+import {
+  isExternalSpoolHidden,
+  setExternalSpoolHidden as persistExternalSpoolHidden,
+} from '../utils/printerCardPrefs';
 import {
   BED_TEMP_DEFAULTS,
   CHAMBER_TEMP_DEFAULTS,
@@ -42,6 +46,8 @@ import {
   Zap,
   Wrench,
   ChevronDown,
+  Eye,
+  EyeOff,
   Filter,
   Pencil,
   ArrowLeft,
@@ -747,6 +753,37 @@ function AmsBackupBadge({ state, onClick }: AmsBackupBadgeProps) {
   );
 }
 
+// Hide/show the external spool in the filament row (#1782). Sized and shaped
+// like AmsBackupBadge so the two sit together in the section header, but
+// pinned to the right-hand end of the rule: this is a view preference for the
+// row, not a property of the printer.
+interface ExternalSpoolToggleProps {
+  hidden: boolean;
+  onClick: () => void;
+}
+
+function ExternalSpoolToggle({ hidden, onClick }: ExternalSpoolToggleProps) {
+  const { t } = useTranslation();
+  const title = hidden ? t('printers.externalSpool.show') : t('printers.externalSpool.hide');
+
+  return (
+    <button
+      type="button"
+      onClick={onClick}
+      aria-pressed={hidden}
+      className={`flex items-center justify-center w-[18px] h-[18px] rounded transition-colors cursor-pointer ${
+        hidden
+          ? 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
+          : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark/80'
+      }`}
+      title={title}
+      aria-label={title}
+    >
+      {hidden ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
+    </button>
+  );
+}
+
 // Humidity indicator with water drop that fills based on level (Bambu Lab style)
 // Reference: https://github.com/theicedmango/bambu-humidity
 interface HumidityIndicatorProps {
@@ -1849,6 +1886,18 @@ function PrinterCard({
   const [showAiModal, setShowAiModal] = useState(false);
   // #1762: AMS Filament Backup status / control modal — opens from the badge.
   const [amsBackupModalOpen, setAmsBackupModalOpen] = useState(false);
+  // External spool visibility (#1782) — browser-local, per printer. Read once
+  // per card; the toggle that writes it is the only thing that changes it.
+  const [externalSpoolHidden, setExternalSpoolHidden] = useState(() =>
+    isExternalSpoolHidden(printer.id),
+  );
+  const toggleExternalSpool = useCallback(() => {
+    setExternalSpoolHidden((prev) => {
+      const next = !prev;
+      persistExternalSpoolHidden(printer.id, next);
+      return next;
+    });
+  }, [printer.id]);
   const [showStopConfirm, setShowStopConfirm] = useState(false);
   const [showPauseConfirm, setShowPauseConfirm] = useState(false);
   const [showSpeedMenu, setShowSpeedMenu] = useState<number | null>(null);
@@ -4635,6 +4684,13 @@ function PrinterCard({
               // Separate regular AMS (4-tray) from HT AMS (1-tray)
               const regularAms = amsData.filter(ams => ams.tray.length > 1);
               const htAms = amsData.filter(ams => ams.tray.length === 1);
+              // The external spool can only be hidden while some AMS remains to
+              // fill the row (#1782). On an A1 Mini or a bare P1P it is the whole
+              // filament section, so the toggle is not offered there and a stored
+              // preference from a printer that later lost its AMS cannot blank the
+              // row either — both read through canHideExternalSpool.
+              const canHideExternalSpool = amsData.length > 0 && status.vt_tray.length > 0;
+              const showExternalSpool = !(canHideExternalSpool && externalSpoolHidden);
               const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
               const filamentSlotClass = 'min-w-14';
               // #1762 (comment 2): while a print is running/paused, overlay a small
@@ -4668,6 +4724,18 @@ function PrinterCard({
                       onClick={() => setAmsBackupModalOpen(true)}
                     />
                     <div className="flex-1 h-[2px] bg-bambu-dark-tertiary" />
+                    {/* Offered only when an AMS is present: on a printer that
+                        feeds from the external spool alone, hiding it would
+                        empty the row entirely (#1782). */}
+                    {canHideExternalSpool && (
+                      <>
+                        <ExternalSpoolToggle
+                          hidden={externalSpoolHidden}
+                          onClick={toggleExternalSpool}
+                        />
+                        <div className="w-3 h-[2px] bg-bambu-dark-tertiary" />
+                      </>
+                    )}
                   </div>
 
                   {/* AMS Content */}
@@ -5515,7 +5583,7 @@ function PrinterCard({
                         );
                       })}
                       {/* External spool(s) - grouped in one card like regular AMS */}
-                      {status.vt_tray.length > 0 && (
+                      {status.vt_tray.length > 0 && showExternalSpool && (
                         <div style={getAmsCardStyle(status.vt_tray.length)} className="min-w-0 p-2 bg-bambu-dark rounded-[10px] space-y-1">
                           <div className="flex w-full min-h-7 items-center gap-1.5 rounded-lg bg-bambu-dark-secondary px-2 py-1">
                             <span className="block min-w-0 flex-1 truncate text-[10px] text-white font-medium">{t('printers.external')}</span>

+ 56 - 0
frontend/src/utils/printerCardPrefs.ts

@@ -0,0 +1,56 @@
+/**
+ * Per-printer view preferences for the printer card.
+ *
+ * These are browser-local, like every other printer-page view preference
+ * (`printerCardSize`, `hideDisconnectedPrinters`, `printerCollapsedSections`).
+ * They describe how one person wants their own screen to look, not anything
+ * about the printer, so they deliberately do not go to the backend.
+ *
+ * Keyed by printer id rather than held as a single global flag: the toggle
+ * lives on the card itself, so hiding the external spool on one printer must
+ * not silently rearrange every other card in a fleet.
+ */
+
+const HIDDEN_EXTERNAL_SPOOLS_KEY = 'printerHiddenExternalSpools';
+
+function readHiddenExternalSpools(): Record<string, boolean> {
+  try {
+    const saved = localStorage.getItem(HIDDEN_EXTERNAL_SPOOLS_KEY);
+    if (!saved) return {};
+    const parsed: unknown = JSON.parse(saved);
+    // Anything that isn't a plain object (an older format, or a value another
+    // tab mangled) is discarded rather than indexed into.
+    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
+    return parsed as Record<string, boolean>;
+  } catch {
+    // Malformed JSON, or localStorage unavailable (private mode / blocked
+    // cookies). Showing the external spool is the safe default either way.
+    return {};
+  }
+}
+
+/** Whether this printer's external spool should be left out of the card. */
+export function isExternalSpoolHidden(printerId: number): boolean {
+  return readHiddenExternalSpools()[String(printerId)] === true;
+}
+
+/**
+ * Persist the toggle. Re-reads before writing so two cards toggled in the same
+ * session can't clobber each other's entry, and drops the key entirely when
+ * shown again so the stored object doesn't accumulate `false` for every printer
+ * the user ever toggled twice.
+ */
+export function setExternalSpoolHidden(printerId: number, hidden: boolean): void {
+  const next = readHiddenExternalSpools();
+  if (hidden) {
+    next[String(printerId)] = true;
+  } else {
+    delete next[String(printerId)];
+  }
+  try {
+    localStorage.setItem(HIDDEN_EXTERNAL_SPOOLS_KEY, JSON.stringify(next));
+  } catch {
+    // Quota exceeded or private mode — the toggle still applies for this
+    // session, it just won't survive a reload.
+  }
+}

Plik diff jest za duży
+ 0 - 0
static/assets/index-COuw8Kkt.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-B2_C9nlv.js"></script>
+    <script type="module" crossorigin src="/assets/index-COuw8Kkt.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-GBTQ2eaA.css">
   </head>
   <body>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików