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.
This commit is contained in:
maziggy
2026-08-04 13:08:30 +02:00
parent 9bc96aeb83
commit 3db8ac9da7
20 changed files with 612 additions and 153 deletions
+1
View File
@@ -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.
@@ -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();
});
});
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -617,6 +617,10 @@ export default {
},
// Filaments section
filaments: 'フィラメント',
externalSpool: {
hide: '外部スプールを非表示にする',
show: '外部スプールを表示する',
},
// Camera
openCameraOverlay: 'カメラオーバーレイを開く',
openCameraWindow: 'カメラを新しいウィンドウで開く',
+4
View File
@@ -580,6 +580,10 @@ export default {
external: '외부 스풀',
},
filaments: '필라멘트',
externalSpool: {
hide: '외부 스풀 숨기기',
show: '외부 스풀 표시',
},
openCameraOverlay: '카메라 오버레이 열기',
openCameraWindow: '새 창에서 카메라 열기',
firmwareUpdateAvailable: '펌웨어 업데이트 가능: {{current}} → {{latest}}',
+4
View File
@@ -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
View File
@@ -585,6 +585,10 @@ export default {
external: "Внешняя катушка",
},
filaments: "Филаменты",
externalSpool: {
hide: "Скрыть внешнюю катушку",
show: "Показать внешнюю катушку",
},
openCameraOverlay: "Открыть камеру поверх интерфейса",
openCameraWindow: "Открыть камеру в новом окне",
firmwareUpdateAvailable: "Доступно обновление прошивки: {{current}} → {{latest}}",
+4
View File
@@ -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
View File
@@ -622,6 +622,10 @@ export default {
},
// Filaments section
filaments: "Філаменти",
externalSpool: {
hide: "Сховати зовнішню котушку",
show: "Показати зовнішню котушку",
},
// Camera
openCameraOverlay: "Відкрити накладання камери",
openCameraWindow: "Відкрити камеру в новому вікні",
+4
View File
@@ -618,6 +618,10 @@ export default {
},
// Filaments section
filaments: '耗材',
externalSpool: {
hide: '隐藏外部料卷',
show: '显示外部料卷',
},
// Camera
openCameraOverlay: '打开摄像头叠加层',
openCameraWindow: '在新窗口中打开摄像头',
+4
View File
@@ -618,6 +618,10 @@ export default {
},
// Filaments section
filaments: '耗材',
externalSpool: {
hide: '隱藏外部料卷',
show: '顯示外部料卷',
},
// Camera
openCameraOverlay: '開啟攝影機疊加層',
openCameraWindow: '在新視窗中開啟攝影機',
+69 -1
View File
@@ -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
View File
@@ -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.
}
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>