From 3db8ac9da709d3fac3bfa358ef000e384be80b81 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 4 Aug 2026 13:08:30 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + .../PrintersPageExternalSpoolToggle.test.tsx | 181 +++++++++++ .../__tests__/utils/printerCardPrefs.test.ts | 101 ++++++ frontend/src/i18n/locales/de.ts | 4 + frontend/src/i18n/locales/en.ts | 4 + frontend/src/i18n/locales/es.ts | 4 + frontend/src/i18n/locales/fr.ts | 4 + frontend/src/i18n/locales/it.ts | 4 + frontend/src/i18n/locales/ja.ts | 4 + frontend/src/i18n/locales/ko.ts | 4 + frontend/src/i18n/locales/pt-BR.ts | 4 + frontend/src/i18n/locales/ru.ts | 4 + frontend/src/i18n/locales/tr.ts | 4 + frontend/src/i18n/locales/uk.ts | 4 + frontend/src/i18n/locales/zh-CN.ts | 4 + frontend/src/i18n/locales/zh-TW.ts | 4 + frontend/src/pages/PrintersPage.tsx | 70 +++- frontend/src/utils/printerCardPrefs.ts | 56 ++++ .../{index-B2_C9nlv.js => index-COuw8Kkt.js} | 302 +++++++++--------- static/index.html | 2 +- 20 files changed, 612 insertions(+), 153 deletions(-) create mode 100644 frontend/src/__tests__/pages/PrintersPageExternalSpoolToggle.test.tsx create mode 100644 frontend/src/__tests__/utils/printerCardPrefs.test.ts create mode 100644 frontend/src/utils/printerCardPrefs.ts rename static/assets/{index-B2_C9nlv.js => index-COuw8Kkt.js} (67%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5823b8193..b49296a18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to Bambuddy will be documented in this file. ### Added - **Temperatures on the streaming overlay, and a builder for its URL (#1422, reporter @SMAW)** — The overlay at `/overlay/{printer}` draws live print data over a full-screen camera view for OBS, a wall display or any browser source. It could already be tuned — which fields, what size, what frame rate — but only through query parameters documented in the wiki, and temperatures were not among the fields on offer. Both are now addressed. Nozzle, bed and chamber readings join the list, shown with the target while the heater is still climbing and with the target dropped once it is reached, so a settled hotend reads "220°C" rather than "220 / 220°C" for the rest of the print. Both nozzles appear on a dual-nozzle printer. They are drawn whether or not a print is running, since a preheating machine is exactly when they are worth watching, and each reading appears only when the printer genuinely reports it — chamber temperature stays absent on P1 and A1 models, which publish a value with no sensor behind it. And **Settings → API Keys → Streaming Overlay** now builds the URL for you: pick the printer, tick the fields, set size and frame rate, paste in a token if login is enabled, and copy the result, with an optional preview alongside it. The preview stays off until you ask for it so that leaving the settings page open does not hold a viewer on the printer's single camera connection. Making that preview possible needed one narrow change to the security headers: the overlay path now sends `frame-ancestors 'self'` instead of `'none'`, so Bambuddy's own UI can embed it. Every other page still refuses to be framed at all, `'self'` permits a framer only on this same origin, and embedding the overlay from another host — Home Assistant on a different port, say — is unchanged and still requires `TRUSTED_FRAME_ORIGINS`. Temperatures are not in the default field set, so an overlay URL already pasted into a scene looks exactly the same after upgrading. Translated in all locales, wiki updated, covered by backend and frontend tests. +- **The external spool can be hidden from the printer card (#1782, reporter @Arn0uDz)** — An external spool holder that never gets used still occupies a full card's width in the **Filaments** row, next to the AMS units that are actually being used. An eye icon at the right-hand end of that row's header now hides it, and clicking it again brings it back, so nothing is lost behind a settings page you would have to remember. The choice is remembered per printer and stored in the browser, like the card size and the offline-printer filter — one machine in a fleet can be tidied up without touching the others, and nothing changes for anyone else using the same Bambuddy. The icon is deliberately absent on a printer with no AMS: there the external spool is the entire filament section, and hiding it would leave an empty row. That guard also covers the case of an AMS being unplugged from a printer whose external spool was hidden earlier — the spool reappears rather than leaving a blank row behind. On the H2D and H2S both external positions share one card and so hide together. Translated in all locales, wiki updated, covered by frontend tests. ### Changed - **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests. diff --git a/frontend/src/__tests__/pages/PrintersPageExternalSpoolToggle.test.tsx b/frontend/src/__tests__/pages/PrintersPageExternalSpoolToggle.test.tsx new file mode 100644 index 000000000..68c21c4c3 --- /dev/null +++ b/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; + +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(); + + 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(); + 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(); + + 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(); + + // 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(); + + await waitFor(() => expect(externalSpoolCards().length).toBeGreaterThan(0)); + expect(screen.queryByTitle(SHOW_TITLE)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/utils/printerCardPrefs.test.ts b/frontend/src/__tests__/utils/printerCardPrefs.test.ts new file mode 100644 index 000000000..4d7fb385d --- /dev/null +++ b/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; + +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(); + }); +}); diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 2b0fcdc8b..2adb916b7 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index f2e45b51b..111494fe9 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index e6ded4121..79b856d56 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 003289d75..8dcbe8348 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index ddaf8daa8..3ac04cec5 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 52e5b0306..b866be8d6 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -617,6 +617,10 @@ export default { }, // Filaments section filaments: 'フィラメント', + externalSpool: { + hide: '外部スプールを非表示にする', + show: '外部スプールを表示する', + }, // Camera openCameraOverlay: 'カメラオーバーレイを開く', openCameraWindow: 'カメラを新しいウィンドウで開く', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 7dc40b280..360804a58 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -580,6 +580,10 @@ export default { external: '외부 스풀', }, filaments: '필라멘트', + externalSpool: { + hide: '외부 스풀 숨기기', + show: '외부 스풀 표시', + }, openCameraOverlay: '카메라 오버레이 열기', openCameraWindow: '새 창에서 카메라 열기', firmwareUpdateAvailable: '펌웨어 업데이트 가능: {{current}} → {{latest}}', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 186d5fcce..ae747b928 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index ab40f45ff..a294e2487 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -585,6 +585,10 @@ export default { external: "Внешняя катушка", }, filaments: "Филаменты", + externalSpool: { + hide: "Скрыть внешнюю катушку", + show: "Показать внешнюю катушку", + }, openCameraOverlay: "Открыть камеру поверх интерфейса", openCameraWindow: "Открыть камеру в новом окне", firmwareUpdateAvailable: "Доступно обновление прошивки: {{current}} → {{latest}}", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 3e37edcdf..b8f9a0f09 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/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ç', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index 26bdf5f7d..e54b958d0 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -622,6 +622,10 @@ export default { }, // Filaments section filaments: "Філаменти", + externalSpool: { + hide: "Сховати зовнішню котушку", + show: "Показати зовнішню котушку", + }, // Camera openCameraOverlay: "Відкрити накладання камери", openCameraWindow: "Відкрити камеру в новому вікні", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index e6fb3285f..0cb51efd9 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -618,6 +618,10 @@ export default { }, // Filaments section filaments: '耗材', + externalSpool: { + hide: '隐藏外部料卷', + show: '显示外部料卷', + }, // Camera openCameraOverlay: '打开摄像头叠加层', openCameraWindow: '在新窗口中打开摄像头', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index a06ef1a0b..5eb2b89f7 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -618,6 +618,10 @@ export default { }, // Filaments section filaments: '耗材', + externalSpool: { + hide: '隱藏外部料卷', + show: '顯示外部料卷', + }, // Camera openCameraOverlay: '開啟攝影機疊加層', openCameraWindow: '在新視窗中開啟攝影機', diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 0da82c5d2..694cb61ad 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/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 ( + + ); +} + // 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(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)} />
+ {/* 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 && ( + <> +