mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
feat: add system theme detection (prefers-color-scheme) (#1501)
feat: add system theme detection (prefers-color-scheme)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ファイルを自動保存',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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文件中提取并保存预览图像',
|
||||
|
||||
@@ -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檔案中提取並儲存預覽影像',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user