feat: add system theme detection (prefers-color-scheme) (#1501)

feat: add system theme detection (prefers-color-scheme)
This commit is contained in:
Timothy Trowbridge
2026-05-25 09:21:53 +02:00
committed by GitHub
parent 554a73070f
commit 052e928107
16 changed files with 376 additions and 34 deletions
@@ -125,6 +125,47 @@ describe('Layout', () => {
expect(buttons.length).toBeGreaterThan(0);
});
});
it('cycles through dark → light → system → dark', async () => {
localStorage.setItem('theme-mode', 'dark');
render(<Layout />);
await waitFor(() => {
// In dark mode, title should say "Switch to light mode"
const btn = document.querySelector('button[title="Switch to light mode"]');
expect(btn).toBeInTheDocument();
});
// Click to go from dark → light
const lightBtn = document.querySelector('button[title="Switch to light mode"]')!;
lightBtn.click();
await waitFor(() => {
// In light mode, title should say "Switch to system mode"
const btn = document.querySelector('button[title="Switch to system mode"]');
expect(btn).toBeInTheDocument();
});
// Click to go from light → system
const systemBtn = document.querySelector('button[title="Switch to system mode"]')!;
systemBtn.click();
await waitFor(() => {
// In system mode, title should say "Switch to dark mode"
const btn = document.querySelector('button[title="Switch to dark mode"]');
expect(btn).toBeInTheDocument();
});
// Click to go from system → dark
const darkBtn = document.querySelector('button[title="Switch to dark mode"]')!;
darkBtn.click();
await waitFor(() => {
// Back to dark mode
const btn = document.querySelector('button[title="Switch to light mode"]');
expect(btn).toBeInTheDocument();
});
});
});
describe('plate detection alert modal', () => {
@@ -0,0 +1,181 @@
/**
* Tests for the ThemeContext system theme detection feature.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { ThemeProvider, useTheme } from '../../contexts/ThemeContext';
import type { ReactNode } from 'react';
// Helper to create a controllable matchMedia mock for individual tests
function mockMatchMedia(prefersDark: boolean) {
let listener: ((e: MediaQueryListEvent) => void) | null = null;
const mql = {
matches: prefersDark,
media: '(prefers-color-scheme: dark)',
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: (_event: string, cb: (e: MediaQueryListEvent) => void) => {
listener = cb;
},
removeEventListener: () => {},
dispatchEvent: () => true,
};
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: () => mql,
});
return {
/** Simulate an OS theme change event */
fireChange: (dark: boolean) => {
mql.matches = dark;
if (listener) {
listener({ matches: dark } as MediaQueryListEvent);
}
},
};
}
function wrapper({ children }: { children: ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}
describe('ThemeContext', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.className = '';
});
describe('systemPreference initialization', () => {
it('initializes systemPreference as dark when OS prefers dark', () => {
mockMatchMedia(true);
const { result } = renderHook(() => useTheme(), { wrapper });
// When mode is system, resolvedMode should follow OS preference
act(() => result.current.setMode('system'));
expect(result.current.resolvedMode).toBe('dark');
});
it('initializes systemPreference as light when OS prefers light', () => {
mockMatchMedia(false);
const { result } = renderHook(() => useTheme(), { wrapper });
act(() => result.current.setMode('system'));
expect(result.current.resolvedMode).toBe('light');
});
});
describe('matchMedia change event', () => {
it('updates systemPreference when OS theme changes', () => {
const { fireChange } = mockMatchMedia(false);
const { result } = renderHook(() => useTheme(), { wrapper });
act(() => result.current.setMode('system'));
expect(result.current.resolvedMode).toBe('light');
act(() => fireChange(true));
expect(result.current.resolvedMode).toBe('dark');
act(() => fireChange(false));
expect(result.current.resolvedMode).toBe('light');
});
});
describe('resolvedMode', () => {
it('follows explicit mode when mode is dark', () => {
mockMatchMedia(false);
const { result } = renderHook(() => useTheme(), { wrapper });
act(() => result.current.setMode('dark'));
expect(result.current.resolvedMode).toBe('dark');
});
it('follows explicit mode when mode is light', () => {
mockMatchMedia(true);
const { result } = renderHook(() => useTheme(), { wrapper });
act(() => result.current.setMode('light'));
expect(result.current.resolvedMode).toBe('light');
});
it('follows systemPreference when mode is system', () => {
const { fireChange } = mockMatchMedia(true);
const { result } = renderHook(() => useTheme(), { wrapper });
act(() => result.current.setMode('system'));
expect(result.current.resolvedMode).toBe('dark');
act(() => fireChange(false));
expect(result.current.resolvedMode).toBe('light');
});
it('ignores OS changes when mode is explicit', () => {
const { fireChange } = mockMatchMedia(false);
const { result } = renderHook(() => useTheme(), { wrapper });
act(() => result.current.setMode('dark'));
act(() => fireChange(true));
expect(result.current.resolvedMode).toBe('dark');
});
});
describe('document root dark class', () => {
it('adds dark class when mode is system and OS prefers dark', () => {
mockMatchMedia(true);
const { result } = renderHook(() => useTheme(), { wrapper });
act(() => result.current.setMode('system'));
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
it('removes dark class when mode is system and OS prefers light', () => {
mockMatchMedia(false);
const { result } = renderHook(() => useTheme(), { wrapper });
act(() => result.current.setMode('system'));
expect(document.documentElement.classList.contains('dark')).toBe(false);
});
it('adds dark class when mode is explicitly dark', () => {
mockMatchMedia(false);
const { result } = renderHook(() => useTheme(), { wrapper });
act(() => result.current.setMode('dark'));
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
describe('toggleMode', () => {
it('cycles dark → light → system → dark', () => {
mockMatchMedia(false);
localStorage.setItem('theme-mode', 'dark');
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.mode).toBe('dark');
act(() => result.current.toggleMode());
expect(result.current.mode).toBe('light');
act(() => result.current.toggleMode());
expect(result.current.mode).toBe('system');
act(() => result.current.toggleMode());
expect(result.current.mode).toBe('dark');
});
});
});
@@ -927,4 +927,65 @@ describe('SettingsPage', () => {
15_000,
);
});
describe('theme mode buttons', () => {
it('renders Dark, Light, and System buttons', async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Dark' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Light' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
});
});
it('highlights the active mode button with green border', async () => {
render(<SettingsPage />);
const user = userEvent.setup();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: 'System' }));
await waitFor(() => {
const systemBtn = screen.getByRole('button', { name: 'System' });
expect(systemBtn.className).toContain('border-bambu-green');
});
});
it('clicking a theme button switches mode', async () => {
localStorage.setItem('theme-mode', 'dark');
render(<SettingsPage />);
const user = userEvent.setup();
await waitFor(() => {
const darkBtn = screen.getByRole('button', { name: 'Dark' });
expect(darkBtn.className).toContain('border-bambu-green');
});
const lightBtn = screen.getByRole('button', { name: 'Light' });
await user.click(lightBtn);
await waitFor(() => {
expect(lightBtn.className).toContain('border-bambu-green');
});
});
it('shows a toast when theme button is clicked', async () => {
render(<SettingsPage />);
const user = userEvent.setup();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: 'System' }));
await waitFor(() => {
expect(screen.getByText('Settings saved')).toBeInTheDocument();
});
});
});
});
+8 -7
View File
@@ -26,18 +26,19 @@ afterEach(() => {
afterAll(() => server.close());
// Mock window.matchMedia for responsive components
// Uses a plain function (not vi.fn) so vi.restoreAllMocks() in tests can't wipe it
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => true,
}),
});
// Mock ResizeObserver
+13 -8
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, Globe, type LucideIcon } from 'lucide-react';
import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, Globe, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -78,9 +78,14 @@ export function setDefaultView(path: string) {
export function Layout() {
const navigate = useNavigate();
const location = useLocation();
const { mode, toggleMode } = useTheme();
const { mode, resolvedMode, toggleMode } = useTheme();
const { t } = useTranslation();
const isSidebarCompact = useIsSidebarCompact();
// Theme toggle: mode → icon and tooltip
const ThemeIcon = { dark: Sun, light: Monitor, system: Moon }[mode];
const themeSwitchTitle = t({ dark: 'nav.switchToLight', light: 'nav.switchToSystem', system: 'nav.switchToDark' }[mode]);
// Re-render Layout (and the page rendered inside <Outlet />) whenever the
// backend color catalog is (re)populated, so pages that mounted before the
// catalog fetched — and cached HSL-fallback color names during their first
@@ -499,7 +504,7 @@ export function Layout() {
<Menu className="w-6 h-6 text-white" />
</button>
<img
src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
src={resolvedMode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
alt="Bambuddy"
className="h-8 ml-3"
/>
@@ -525,7 +530,7 @@ export function Layout() {
{/* Logo */}
<div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isSidebarCompact || sidebarExpanded ? 'p-4' : 'p-2'}`}>
<img
src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
src={resolvedMode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
alt="Bambuddy"
className={isSidebarCompact || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
/>
@@ -753,9 +758,9 @@ export function Layout() {
<button
onClick={toggleMode}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
title={themeSwitchTitle}
>
{mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
<ThemeIcon className="w-5 h-5" />
</button>
{authEnabled && user && (
<>
@@ -858,9 +863,9 @@ export function Layout() {
<button
onClick={toggleMode}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
title={themeSwitchTitle}
>
{mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
<ThemeIcon className="w-5 h-5" />
</button>
{authEnabled && user && (
<>
+28 -5
View File
@@ -1,7 +1,7 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { api } from '../api/client';
type ThemeMode = 'light' | 'dark';
type ThemeMode = 'light' | 'dark' | 'system';
type ThemeStyle = 'classic' | 'glow' | 'vibrant';
type DarkBackground = 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
type LightBackground = 'neutral' | 'warm' | 'cool';
@@ -9,6 +9,7 @@ type ThemeAccent = 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
interface ThemeContextType {
mode: ThemeMode;
resolvedMode: 'light' | 'dark';
// Dark mode settings
darkStyle: ThemeStyle;
darkBackground: DarkBackground;
@@ -38,6 +39,23 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
return stored || legacy || 'dark';
});
// System preference detection
const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(() => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
setSystemPreference(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
// Resolved mode: what's actually applied (always 'light' or 'dark')
const resolvedMode: 'light' | 'dark' = mode === 'system' ? systemPreference : mode;
// Dark mode settings
const [darkStyle, setDarkStyleState] = useState<ThemeStyle>(() => {
return (localStorage.getItem('dark-style') as ThemeStyle) || 'vibrant';
@@ -104,8 +122,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
'accent-green', 'accent-teal', 'accent-blue', 'accent-orange', 'accent-purple', 'accent-red'
);
// Apply based on current mode
if (mode === 'dark') {
// Apply based on resolved mode
if (resolvedMode === 'dark') {
root.classList.add('dark');
root.classList.add(`style-${darkStyle}`);
root.classList.add(`bg-${darkBackground}`);
@@ -118,9 +136,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
localStorage.setItem('theme-mode', mode);
localStorage.removeItem('theme');
}, [mode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent]);
}, [mode, resolvedMode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent]);
const toggleMode = () => setModeState(prev => prev === 'dark' ? 'light' : 'dark');
const toggleMode = () => setModeState(prev => {
if (prev === 'dark') return 'light';
if (prev === 'light') return 'system';
return 'dark';
});
const setMode = (m: ThemeMode) => setModeState(m);
// Dark setters
@@ -160,6 +182,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<ThemeContext.Provider value={{
mode,
resolvedMode,
darkStyle, darkBackground, darkAccent,
lightStyle, lightBackground, lightAccent,
toggleMode, setMode,
+2 -1
View File
@@ -24,6 +24,7 @@ export default {
keyboardShortcuts: 'Tastaturkürzel (?)',
switchToLight: 'Zum hellen Modus wechseln',
switchToDark: 'Zum dunklen Modus wechseln',
switchToSystem: 'Zum Systemmodus wechseln',
smartSwitches: 'Smart Switches',
logout: 'Abmelden',
installApp: 'App installieren',
@@ -2141,7 +2142,7 @@ export default {
styleClassic: 'Klassisch',
styleGlow: 'Leuchtend',
styleVibrant: 'Lebendig',
themeToggleHint: 'Zwischen Hell- und Dunkelmodus mit dem Sonnen-/Mondsymbol in der Seitenleiste wechseln.',
themeToggleHint: 'Zwischen Dunkel-, Hell- und Systemmodus mit dem Symbol in der Seitenleiste wechseln.',
// Archive
autoArchivePrints: 'Drucke automatisch archivieren',
autoArchiveDescription: '3MF-Dateien automatisch speichern, wenn Drucke abgeschlossen sind',
+2 -1
View File
@@ -24,6 +24,7 @@ export default {
keyboardShortcuts: 'Keyboard shortcuts (?)',
switchToLight: 'Switch to light mode',
switchToDark: 'Switch to dark mode',
switchToSystem: 'Switch to system mode',
smartSwitches: 'Smart Switches',
logout: 'Logout',
installApp: 'Install app',
@@ -2144,7 +2145,7 @@ export default {
styleClassic: 'Classic',
styleGlow: 'Glow',
styleVibrant: 'Vibrant',
themeToggleHint: 'Toggle between dark and light mode using the sun/moon icon in the sidebar.',
themeToggleHint: 'Toggle between dark, light, and system mode using the icon in the sidebar.',
// Archive
autoArchivePrints: 'Auto-archive prints',
autoArchiveDescription: 'Automatically save 3MF files when prints complete',
+2 -1
View File
@@ -24,6 +24,7 @@ export default {
keyboardShortcuts: 'Atajos de teclado (?)',
switchToLight: 'Cambiar a modo claro',
switchToDark: 'Cambiar a modo oscuro',
switchToSystem: 'Cambiar a modo del sistema',
smartSwitches: 'Interruptores inteligentes',
logout: 'Cerrar sesión',
installApp: 'Instalar app',
@@ -2144,7 +2145,7 @@ export default {
styleClassic: 'Clásico',
styleGlow: 'Resplandor',
styleVibrant: 'Vibrante',
themeToggleHint: 'Alterne entre el modo oscuro y claro con el icono del sol/luna en la barra lateral.',
themeToggleHint: 'Alterne entre modo oscuro, claro y sistema con el icono en la barra lateral.',
// Archive
autoArchivePrints: 'Archivar impresiones automáticamente',
autoArchiveDescription: 'Guardar automáticamente los archivos 3MF cuando se completan las impresiones',
+2 -1
View File
@@ -24,6 +24,7 @@ export default {
keyboardShortcuts: 'Raccourcis clavier (?)',
switchToLight: 'Passer au mode clair',
switchToDark: 'Passer au mode sombre',
switchToSystem: 'Passer au mode système',
smartSwitches: 'Interrupteurs intelligents',
logout: 'Déconnexion',
installApp: "Installer l'application",
@@ -2096,7 +2097,7 @@ export default {
styleClassic: 'Classique',
styleGlow: 'Lumineux',
styleVibrant: 'Vif',
themeToggleHint: 'Basculer entre le mode sombre et clair avec l\'icône soleil/lune dans la barre latérale.',
themeToggleHint: 'Basculer entre le mode sombre, clair et système avec l\'icône dans la barre latérale.',
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',
+2 -1
View File
@@ -24,6 +24,7 @@ export default {
keyboardShortcuts: 'Scorciatoie da tastiera (?)',
switchToLight: 'Passa a tema chiaro',
switchToDark: 'Passa a tema scuro',
switchToSystem: 'Passa a tema di sistema',
smartSwitches: 'Interruttori Smart',
logout: 'Esci',
installApp: 'Installa app',
@@ -2095,7 +2096,7 @@ export default {
styleClassic: 'Classico',
styleGlow: 'Luminoso',
styleVibrant: 'Vibrante',
themeToggleHint: 'Passa tra modalità scura e chiara con l\'icona sole/luna nella barra laterale.',
themeToggleHint: 'Passa tra modalità scura, chiara e sistema con l\'icona nella barra laterale.',
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',
+2 -1
View File
@@ -24,6 +24,7 @@ export default {
keyboardShortcuts: 'キーボードショートカット (?)',
switchToLight: 'ライトモードに切替',
switchToDark: 'ダークモードに切替',
switchToSystem: 'システムモードに切替',
smartSwitches: 'スマートスイッチ',
logout: 'ログアウト',
installApp: 'アプリをインストール',
@@ -2140,7 +2141,7 @@ export default {
styleClassic: 'クラシック',
styleGlow: 'グロー',
styleVibrant: 'ビビッド',
themeToggleHint: 'サイドバーの太陽/月アイコンでダークモードとライトモードを切り替えます。',
themeToggleHint: 'サイドバーのアイコンでダーク、ライト、システムモードを切り替えます。',
// Archive
autoArchivePrints: '印刷を自動アーカイブ',
autoArchiveDescription: '印刷完了時に3MFファイルを自動保存',
+2 -1
View File
@@ -24,6 +24,7 @@ export default {
keyboardShortcuts: 'Atalhos de teclado (?)',
switchToLight: 'Mudar para modo claro',
switchToDark: 'Mudar para modo escuro',
switchToSystem: 'Mudar para modo do sistema',
smartSwitches: 'Interruptores inteligentes',
logout: 'Sair',
installApp: 'Instalar app',
@@ -2095,7 +2096,7 @@ export default {
styleClassic: 'Clássico',
styleGlow: 'Brilhante',
styleVibrant: 'Vibrante',
themeToggleHint: 'Alternar entre modo escuro e claro usando o ícone de sol/lua na barra lateral.',
themeToggleHint: 'Alternar entre modo escuro, claro e sistema usando o ícone na barra lateral.',
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',
+2 -1
View File
@@ -24,6 +24,7 @@ export default {
keyboardShortcuts: '键盘快捷键 (?)',
switchToLight: '切换到浅色模式',
switchToDark: '切换到深色模式',
switchToSystem: '切换到系统模式',
smartSwitches: '智能开关',
logout: '退出登录',
installApp: '安装应用',
@@ -2140,7 +2141,7 @@ export default {
styleClassic: '经典',
styleGlow: '发光',
styleVibrant: '鲜艳',
themeToggleHint: '使用侧边栏中的太阳/月亮图标在深色和浅色模式之间切换。',
themeToggleHint: '使用侧边栏中的图标在深色、浅色和系统模式之间切换。',
autoArchivePrints: '自动归档打印',
autoArchiveDescription: '打印完成时自动保存3MF文件',
saveThumbnailsDescription: '从3MF文件中提取并保存预览图像',
+2 -1
View File
@@ -24,6 +24,7 @@ export default {
keyboardShortcuts: '鍵盤快捷鍵 (?)',
switchToLight: '切換到淺色模式',
switchToDark: '切換到深色模式',
switchToSystem: '切換到系統模式',
smartSwitches: '智慧開關',
logout: '登出',
installApp: '安裝應用程式',
@@ -2140,7 +2141,7 @@ export default {
styleClassic: '經典',
styleGlow: '發光',
styleVibrant: '鮮豔',
themeToggleHint: '使用側邊欄中的太陽/月亮圖示在深色和淺色模式之間切換。',
themeToggleHint: '使用側邊欄中的圖示在深色、淺色和系統模式之間切換。',
autoArchivePrints: '自動歸檔列印',
autoArchiveDescription: '列印完成時自動儲存3MF檔案',
saveThumbnailsDescription: '從3MF檔案中提取並儲存預覽影像',
+26 -5
View File
@@ -158,9 +158,10 @@ export function SettingsPage() {
const { showToast } = useToast();
const { authEnabled, user, isAdmin, refreshAuth, hasPermission } = useAuth();
const {
mode,
mode, resolvedMode,
darkStyle, darkBackground, darkAccent,
lightStyle, lightBackground, lightAccent,
setMode,
setDarkStyle, setDarkBackground, setDarkAccent,
setLightStyle, setLightBackground, setLightAccent,
} = useTheme();
@@ -1638,11 +1639,31 @@ export function SettingsPage() {
</h2>
</CardHeader>
<CardContent className="space-y-3">
{/* Theme Mode Selector */}
<div className="flex items-center gap-2 mb-2">
<label className="text-sm text-bambu-gray">{t('settings.theme')}:</label>
<div className="flex gap-1">
{([
{ id: 'dark', label: t('settings.themeDark') },
{ id: 'light', label: t('settings.themeLight') },
{ id: 'system', label: t('settings.themeSystem') },
] as const).map(({ id, label }) => (
<button
key={id}
onClick={() => { setMode(id); showToast(t('settings.toast.settingsSaved'), 'success'); }}
className={`px-3 py-1 text-xs rounded-lg border transition-colors ${mode === id ? 'border-bambu-green bg-bambu-green/10 text-bambu-green' : 'border-gray-300 dark:border-bambu-dark-tertiary text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white cursor-pointer'}`}
>
{label}
</button>
))}
</div>
</div>
{/* Dark Mode Settings */}
<div className={`space-y-3 p-4 rounded-lg border ${mode === 'dark' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
<div className={`space-y-3 p-4 rounded-lg border ${resolvedMode === 'dark' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
<h3 className="text-sm font-medium text-white flex items-center gap-2">
{t('settings.darkMode')}
{mode === 'dark' && <span className="text-xs text-bambu-green">{t('settings.active')}</span>}
{resolvedMode === 'dark' && <span className="text-xs text-bambu-green">{t('settings.active')}</span>}
</h3>
<div className="grid grid-cols-3 gap-3">
<div>
@@ -1691,10 +1712,10 @@ export function SettingsPage() {
</div>
{/* Light Mode Settings */}
<div className={`space-y-3 p-4 rounded-lg border ${mode === 'light' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
<div className={`space-y-3 p-4 rounded-lg border ${resolvedMode === 'light' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
<h3 className="text-sm font-medium text-white flex items-center gap-2">
{t('settings.lightMode')}
{mode === 'light' && <span className="text-xs text-bambu-green">{t('settings.active')}</span>}
{resolvedMode === 'light' && <span className="text-xs text-bambu-green">{t('settings.active')}</span>}
</h3>
<div className="grid grid-cols-3 gap-3">
<div>