diff --git a/README.md b/README.md index 6355251dc..1a281c22e 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R ### 📊 Monitoring & Control - Real-time printer status via WebSocket +- **Print progress in the browser tab** — optional (off by default, toggle under Settings → Appearance): shows the soonest-finishing print's percentage in the tab title and a progress-ring favicon in your theme accent colour - Live camera streaming (MJPEG) & snapshots with multi-viewer support — most Bambu printers only allow one upstream connection, so Bambuddy fans out a single shared stream to all browser tabs / cards / overlays - **Cam Wall view** — Toggle the Printers page from cards into a responsive grid of camera tiles for at-a-glance monitoring across the whole farm. On-screen tiles stream live up to a configurable cap (default 4) so RPi installs stay sustainable; the rest fall back to periodic snapshot polling, and off-screen tiles pause entirely. Per-user settings (live cap, snapshot interval); click any tile to open the floating viewer or the dedicated camera window depending on your existing camera-view preference - **Long-lived camera tokens** for Home Assistant / Frigate / kiosks — mint a token from Settings → API Keys, paste it once, capped at 365 days, revocable at any time (no infinite tokens — leaked permanent tokens are unsafe by design) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ead293d2f..690511752 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ import { SetupPage } from './pages/SetupPage'; import { NotificationsPage } from './pages/NotificationsPage'; import { GCodeViewerPage } from './pages/GCodeViewerPage'; import { useWebSocket } from './hooks/useWebSocket'; +import { usePrintProgressTitle } from './hooks/usePrintProgressTitle'; import { useStreamTokenSync } from './hooks/useCameraStreamToken'; import { ThemeProvider } from './contexts/ThemeContext'; import { ToastProvider } from './contexts/ToastContext'; @@ -89,6 +90,7 @@ function StreamTokenSync() { function WebSocketProvider({ children }: { children: React.ReactNode }) { useWebSocket(); + usePrintProgressTitle(); return <>{children}>; } diff --git a/frontend/src/__tests__/hooks/usePrintProgressTitle.test.tsx b/frontend/src/__tests__/hooks/usePrintProgressTitle.test.tsx new file mode 100644 index 000000000..89d7c6e04 --- /dev/null +++ b/frontend/src/__tests__/hooks/usePrintProgressTitle.test.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from 'react'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +// Mock the theme pref and the API the hook reads, so the effect can be exercised +// without the real providers. `theme.value` is swapped per test. +const h = vi.hoisted(() => ({ + theme: { + value: { + progressInTitle: false, + resolvedMode: 'dark', + darkAccent: 'green', + lightAccent: 'green', + } as { progressInTitle: boolean; resolvedMode: string; darkAccent: string; lightAccent: string }, + }, + getPrinters: vi.fn(), + getPrinterStatus: vi.fn(), +})); + +vi.mock('../../contexts/ThemeContext', () => ({ useTheme: () => h.theme.value })); +vi.mock('../../api/client', () => ({ + api: { getPrinters: h.getPrinters, getPrinterStatus: h.getPrinterStatus }, +})); + +import { pickActivePrint, usePrintProgressTitle, type ProgressStatus } from '../../hooks/usePrintProgressTitle'; + +const running = (progress: number, remaining_time: number | null): ProgressStatus => ({ + state: 'RUNNING', + progress, + remaining_time, +}); + +describe('pickActivePrint', () => { + it('returns null when nothing is printing', () => { + expect(pickActivePrint([])).toBeNull(); + expect(pickActivePrint([undefined])).toBeNull(); + expect(pickActivePrint([{ state: 'IDLE', progress: 0, remaining_time: null }])).toBeNull(); + // The real paused state is 'PAUSE', not 'PAUSED'. + expect(pickActivePrint([{ state: 'PAUSE', progress: 40, remaining_time: 10 }])).toBeNull(); + }); + + it('ignores RUNNING prints with no progress value', () => { + expect(pickActivePrint([{ state: 'RUNNING', progress: null, remaining_time: 5 }])).toBeNull(); + }); + + it('picks the soonest-finishing print among several running', () => { + const soonest = running(20, 12); + expect(pickActivePrint([running(80, 45), soonest, running(50, 30)])).toBe(soonest); + }); + + it('tie-breaks equal ETAs by highest progress', () => { + const further = running(70, 15); + expect(pickActivePrint([running(30, 15), further])).toBe(further); + }); + + it('treats a null remaining_time as furthest away', () => { + const withEta = running(10, 60); + expect(pickActivePrint([running(90, null), withEta])).toBe(withEta); + }); + + it('treats remaining_time <= 0 as unknown — a just-started print must not win', () => { + // The backend serialises "ETA not known yet" as 0 (not null). A printer that + // just started (0) must not steal the tab from one that is nearly done. + const almostDone = running(95, 180); + expect(pickActivePrint([running(2, 0), almostDone])).toBe(almostDone); + expect(pickActivePrint([running(2, -1), almostDone])).toBe(almostDone); + }); +}); + +function wrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +describe('usePrintProgressTitle effect', () => { + beforeEach(() => { + h.getPrinters.mockReset(); + h.getPrinterStatus.mockReset(); + document.title = 'Bambuddy'; + }); + afterEach(() => cleanup()); + + it('is inert while the pref is off — never touches the tab title', async () => { + h.theme.value = { progressInTitle: false, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' }; + document.title = 'Something Else'; + + renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() }); + + await new Promise((r) => setTimeout(r, 20)); + expect(document.title).toBe('Something Else'); + expect(h.getPrinters).not.toHaveBeenCalled(); + }); + + it('shows the active print percentage in the title when enabled', async () => { + h.theme.value = { progressInTitle: true, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' }; + h.getPrinters.mockResolvedValue([{ id: 1 }]); + h.getPrinterStatus.mockResolvedValue({ state: 'RUNNING', progress: 42, remaining_time: 600 }); + + renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() }); + + await waitFor(() => expect(document.title).toBe('42% · Bambuddy')); + }); +}); diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx index bc2884464..720b46465 100644 --- a/frontend/src/contexts/ThemeContext.tsx +++ b/frontend/src/contexts/ThemeContext.tsx @@ -19,6 +19,9 @@ interface ThemeContextType { lightStyle: ThemeStyle; lightBackground: LightBackground; lightAccent: ThemeAccent; + // Show live print progress (% + green ring favicon) in the browser tab + progressInTitle: boolean; + setProgressInTitle: (v: boolean) => void; // Actions toggleMode: () => void; setMode: (mode: ThemeMode) => void; @@ -87,6 +90,17 @@ export function ThemeProvider({ children }: { children: ReactNode }) { return (localStorage.getItem('light-accent') as ThemeAccent) || 'green'; }); + // Client-only pref (localStorage), no api.updateSettings sync — the tab + // title/favicon is per-browser behaviour. Move to server settings if it + // ever needs to follow the user across devices. Default off. + const [progressInTitle, setProgressInTitleState] = useState(() => { + return localStorage.getItem('progress-in-title') === 'true'; + }); + const setProgressInTitle = (v: boolean) => { + setProgressInTitleState(v); + localStorage.setItem('progress-in-title', String(v)); + }; + // Sync from API once auth state is known. Same gate shape as // useStreamTokenSync / ColorCatalogProvider: wait for AuthContext to // settle, then only fetch when we can actually expect a 200 (auth @@ -202,6 +216,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) { resolvedMode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent, + progressInTitle, setProgressInTitle, toggleMode, setMode, setDarkStyle, setDarkBackground, setDarkAccent, setLightStyle, setLightBackground, setLightAccent, diff --git a/frontend/src/hooks/usePrintProgressTitle.ts b/frontend/src/hooks/usePrintProgressTitle.ts new file mode 100644 index 000000000..ac8ec8381 --- /dev/null +++ b/frontend/src/hooks/usePrintProgressTitle.ts @@ -0,0 +1,155 @@ +import { useQueries, useQuery } from '@tanstack/react-query'; +import { useEffect, useRef } from 'react'; +import { api } from '../api/client'; +import { useTheme } from '../contexts/ThemeContext'; + +const DEFAULT_TITLE = 'Bambuddy'; +const FALLBACK_ACCENT = '#00ae42'; // Bambuddy green, if --accent can't be read (e.g. jsdom) + +// A remaining_time <= 0 means "ETA not known yet" (the backend defaults it to 0, +// not null), so treat it as unknown rather than "finishes now". +const eta = (t: number | null): number => (t != null && t > 0 ? t : Infinity); + +// Only the fields we need — keeps pickActivePrint decoupled from the full +// PrinterStatus type so the test can pass plain objects. +export interface ProgressStatus { + state: string | null; + progress: number | null; + remaining_time: number | null; +} + +/** + * Of all connected printers, pick the RUNNING print to surface in the tab: + * the one finishing soonest (smallest remaining_time), tie-broken by highest + * progress. Returns null when nothing is actively printing. + */ +export function pickActivePrint(statuses: (T | undefined)[]): T | null { + let best: T | null = null; + for (const s of statuses) { + if (!s || s.state !== 'RUNNING' || s.progress == null) continue; + if (best === null) { + best = s; + continue; + } + const sr = eta(s.remaining_time); + const br = eta(best.remaining_time); + if (sr < br || (sr === br && (s.progress ?? 0) > (best.progress ?? 0))) { + best = s; + } + } + return best; +} + +// Draw a 32x32 progress ring in the current theme accent colour, return a PNG +// data URL (or null if the browser has no 2d canvas, e.g. under jsdom — the +// caller then falls back to updating the title only). +function drawProgressFavicon(pct: number): string | null { + const canvas = document.createElement('canvas'); + canvas.width = 32; + canvas.height = 32; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + const accent = + getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || + FALLBACK_ACCENT; + + const cx = 16; + const cy = 16; + const r = 13; + const frac = Math.max(0, Math.min(100, pct)) / 100; + const start = -Math.PI / 2; // 12 o'clock + + ctx.lineWidth = 4; + // Track + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(128,128,128,0.3)'; + ctx.stroke(); + // Progress arc, clockwise from the top + ctx.beginPath(); + ctx.arc(cx, cy, r, start, start + frac * Math.PI * 2); + ctx.strokeStyle = accent; + ctx.lineCap = 'round'; + ctx.stroke(); + + return canvas.toDataURL('image/png'); +} + +// Point the tags at the ring (remembering originals), or +// restore them when dataUrl is null. apple-touch-icon is a different rel token +// so the `rel~="icon"` selector leaves it alone. +function setFavicon(dataUrl: string | null, originals: Map) { + const links = document.querySelectorAll('link[rel~="icon"]'); + links.forEach((link) => { + if (dataUrl) { + if (!originals.has(link)) originals.set(link, link.href); + link.href = dataUrl; + } else { + const orig = originals.get(link); + if (orig !== undefined) link.href = orig; + } + }); + if (!dataUrl) originals.clear(); +} + +/** + * When the "progress in tab" preference is on, reflect the soonest-finishing + * print's percentage in document.title and draw a progress ring favicon in the + * theme accent colour. Stays fully inert until enabled, and hands the tab back + * to its defaults once disabled, idle, or unmounted. + * Mounted once, globally, inside WebSocketProvider. + */ +export function usePrintProgressTitle() { + const { progressInTitle, resolvedMode, darkAccent, lightAccent } = useTheme(); + // Re-draw the ring when the active accent changes. + const accent = resolvedMode === 'dark' ? darkAccent : lightAccent; + + const { data: printers } = useQuery({ + queryKey: ['printers'], + queryFn: api.getPrinters, + enabled: progressInTitle, + }); + + const statusQueries = useQueries({ + queries: (progressInTitle ? printers ?? [] : []).map((p) => ({ + queryKey: ['printerStatus', p.id], + queryFn: () => api.getPrinterStatus(p.id), + refetchInterval: 30000, // fallback; WebSocket drives live updates + })), + }); + + const originalsRef = useRef>(new Map()); + // Whether we currently own the tab title/favicon. Lets us stay inert while + // off (never touch the tab) yet still restore once if we ever took it over. + const ownsRef = useRef(false); + + const active = progressInTitle ? pickActivePrint(statusQueries.map((q) => q.data)) : null; + const pct = active && active.progress != null ? Math.round(active.progress) : null; + + useEffect(() => { + if (progressInTitle && pct != null) { + document.title = `${pct}% · ${DEFAULT_TITLE}`; + setFavicon(drawProgressFavicon(pct), originalsRef.current); + ownsRef.current = true; + } else if (ownsRef.current) { + // Disabled or idle after having taken over — hand the tab back. + document.title = DEFAULT_TITLE; + setFavicon(null, originalsRef.current); + ownsRef.current = false; + } + // else: never owned the tab → leave it entirely alone. + }, [progressInTitle, pct, accent]); + + // Restore the tab to defaults on unmount, but only if we own it. + useEffect(() => { + const originals = originalsRef.current; + const owns = ownsRef; + return () => { + if (owns.current) { + document.title = DEFAULT_TITLE; + setFavicon(null, originals); + } + }; + }, []); +} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 66c1afc25..bd2f8240a 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -2436,6 +2436,8 @@ export default { styleGlow: 'Leuchtend', styleVibrant: 'Lebendig', themeToggleHint: 'Zwischen Dunkel-, Hell- und Systemmodus mit dem Symbol in der Seitenleiste wechseln.', + progressInTitle: 'Druckfortschritt im Tab', + progressInTitleDescription: 'Zeigt den Prozentsatz des aktiven Drucks und einen Fortschrittsring im Browser-Tab an.', // Archive autoArchivePrints: 'Drucke automatisch archivieren', autoArchiveDescription: '3MF-Dateien automatisch speichern, wenn Drucke abgeschlossen sind', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index af7b32ebf..f0264d432 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2455,6 +2455,8 @@ export default { styleGlow: 'Glow', styleVibrant: 'Vibrant', themeToggleHint: 'Toggle between dark, light, and system mode using the icon in the sidebar.', + progressInTitle: 'Print progress in tab', + progressInTitleDescription: 'Show the active print\'s percentage and a progress ring in the browser tab.', // Archive autoArchivePrints: 'Auto-archive prints', autoArchiveDescription: 'Automatically save 3MF files when prints complete', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 47f56cd72..bb8a72f09 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -2439,6 +2439,8 @@ export default { styleGlow: 'Resplandor', styleVibrant: 'Vibrante', themeToggleHint: 'Alterne entre modo oscuro, claro y sistema con el icono en la barra lateral.', + progressInTitle: 'Progreso en la pestaña', + progressInTitleDescription: 'Muestra el porcentaje de la impresión activa y un anillo de progreso en la pestaña del navegador.', // Archive autoArchivePrints: 'Archivar impresiones automáticamente', autoArchiveDescription: 'Guardar automáticamente los archivos 3MF cuando se completan las impresiones', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 94ad1fff5..b72112bd2 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -2391,6 +2391,8 @@ export default { styleGlow: 'Lumineux', styleVibrant: 'Vif', themeToggleHint: 'Basculer entre le mode sombre, clair et système avec l\'icône dans la barre latérale.', + progressInTitle: 'Progression dans l\'onglet', + progressInTitleDescription: 'Affiche le pourcentage de l\'impression en cours et un anneau de progression dans l\'onglet du navigateur.', autoArchivePrints: 'Archiver automatiquement les impressions', autoArchiveDescription: 'Sauvegarder automatiquement les fichiers 3MF à la fin des impressions', saveThumbnailsDescription: 'Extraire et sauvegarder les images d\'aperçu des fichiers 3MF', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index e5496e061..230e31005 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -2390,6 +2390,8 @@ export default { styleGlow: 'Luminoso', styleVibrant: 'Vibrante', themeToggleHint: 'Passa tra modalità scura, chiara e sistema con l\'icona nella barra laterale.', + progressInTitle: 'Avanzamento nella scheda', + progressInTitleDescription: 'Mostra la percentuale della stampa attiva e un anello di avanzamento nella scheda del browser.', autoArchivePrints: 'Archiviazione automatica stampe', autoArchiveDescription: 'Salva automaticamente i file 3MF al completamento delle stampe', saveThumbnailsDescription: 'Estrai e salva le immagini di anteprima dai file 3MF', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 268cc2799..66f767672 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -2435,6 +2435,8 @@ export default { styleGlow: 'グロー', styleVibrant: 'ビビッド', themeToggleHint: 'サイドバーのアイコンでダーク、ライト、システムモードを切り替えます。', + progressInTitle: 'タブに印刷の進捗を表示', + progressInTitleDescription: 'ブラウザのタブに進行中の印刷の進捗率と進捗リングを表示します。', // Archive autoArchivePrints: '印刷を自動アーカイブ', autoArchiveDescription: '印刷完了時に3MFファイルを自動保存', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 50714a900..fb1212c85 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -2306,6 +2306,8 @@ export default { styleGlow: '글로우', styleVibrant: '비브런트', themeToggleHint: '사이드바의 태양/달 아이콘으로 다크 모드와 라이트 모드를 전환하세요.', + progressInTitle: '탭에 인쇄 진행률 표시', + progressInTitleDescription: '브라우저 탭에 진행 중인 인쇄의 백분율과 진행 링을 표시합니다.', autoArchivePrints: '인쇄 자동 아카이브', autoArchiveDescription: '인쇄 완료 시 3MF 파일 자동 저장', saveThumbnailsDescription: '3MF 파일에서 미리보기 이미지 추출 및 저장', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 659c18dd3..d2139d404 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -2390,6 +2390,8 @@ export default { styleGlow: 'Brilhante', styleVibrant: 'Vibrante', themeToggleHint: 'Alternar entre modo escuro, claro e sistema usando o ícone na barra lateral.', + progressInTitle: 'Progresso na aba', + progressInTitleDescription: 'Mostra a porcentagem da impressão ativa e um anel de progresso na aba do navegador.', autoArchivePrints: 'Arquivar impressões automaticamente', autoArchiveDescription: 'Salvar automaticamente arquivos 3MF quando impressões forem concluídas', saveThumbnailsDescription: 'Extrair e salvar imagens de pré-visualização dos arquivos 3MF', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 1470d0210..0194db58d 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -2307,6 +2307,8 @@ export default { styleGlow: "Свечение", styleVibrant: "Насыщенный", themeToggleHint: "Переключайте тёмную, светлую и системную тему значком в боковой панели.", + progressInTitle: "Прогресс во вкладке", + progressInTitleDescription: "Показывает процент текущей печати и кольцо прогресса во вкладке браузера.", autoArchivePrints: "Автоматически архивировать печать", autoArchiveDescription: "Автоматически сохранять 3MF после завершения печати", saveThumbnailsDescription: "Извлекать и сохранять изображения предпросмотра из 3MF", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 3afb2e0ef..8e0ccdfcd 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -2440,6 +2440,8 @@ export default { styleGlow: 'Parıltı', styleVibrant: 'Canlı', themeToggleHint: 'Kenar çubuğundaki güneş/ay simgesini kullanarak koyu ve açık mod arasında geçiş yapın.', + progressInTitle: 'Sekmede baskı ilerlemesi', + progressInTitleDescription: 'Tarayıcı sekmesinde etkin baskının yüzdesini ve bir ilerleme halkası gösterir.', // Arşiv autoArchivePrints: 'Baskıları otomatik arşivle', autoArchiveDescription: 'Baskılar tamamlandığında 3MF dosyalarını otomatik olarak kaydet', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index b92e44e33..dbee37e56 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -2455,6 +2455,8 @@ export default { styleGlow: "Світіння", styleVibrant: "Яскравий", themeToggleHint: "Перемикайтеся між темним, світлим і системним режимами за допомогою значка на бічній панелі.", + progressInTitle: "Прогрес у вкладці", + progressInTitleDescription: "Показує відсоток активного друку та кільце прогресу на вкладці браузера.", // Archive autoArchivePrints: "Автоматично архівувати друки", autoArchiveDescription: "Автоматично зберігати файли 3MF після завершення друку", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index ec3077c3e..185574504 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -2435,6 +2435,8 @@ export default { styleGlow: '发光', styleVibrant: '鲜艳', themeToggleHint: '使用侧边栏中的图标在深色、浅色和系统模式之间切换。', + progressInTitle: '在标签页显示打印进度', + progressInTitleDescription: '在浏览器标签页中显示当前打印的百分比和进度环。', autoArchivePrints: '自动归档打印', autoArchiveDescription: '打印完成时自动保存3MF文件', saveThumbnailsDescription: '从3MF文件中提取并保存预览图像', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index ce13a6729..41b286703 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -2435,6 +2435,8 @@ export default { styleGlow: '發光', styleVibrant: '鮮豔', themeToggleHint: '使用側邊欄中的圖示在深色、淺色和系統模式之間切換。', + progressInTitle: '在分頁顯示列印進度', + progressInTitleDescription: '在瀏覽器分頁中顯示目前列印的百分比和進度環。', autoArchivePrints: '自動歸檔列印', autoArchiveDescription: '列印完成時自動儲存3MF檔案', saveThumbnailsDescription: '從3MF檔案中提取並儲存預覽影像', diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index c7313749e..c97a95444 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -172,6 +172,7 @@ export function SettingsPage() { setMode, setDarkStyle, setDarkBackground, setDarkAccent, setLightStyle, setLightBackground, setLightAccent, + progressInTitle, setProgressInTitle, } = useTheme(); const [localSettings, setLocalSettings] = useState(null); // Transient typed strings for the per-filament humidity threshold inputs @@ -1811,6 +1812,24 @@ export function SettingsPage() { {t('settings.themeToggleHint')} + + + + {t('settings.progressInTitle')} + + {t('settings.progressInTitleDescription')} + + + + { setProgressInTitle(e.target.checked); showToast(t('settings.toast.settingsSaved'), 'success'); }} + className="sr-only peer" + /> + + +
{t('settings.themeToggleHint')}
{t('settings.progressInTitle')}
+ {t('settings.progressInTitleDescription')} +