feat: add System theme option that follows OS color scheme
Adds a 'System' option to the theme switcher that automatically uses light or dark theme based on the OS prefers-color-scheme setting. Dynamically updates when the OS preference changes (e.g. scheduled dark mode). i18n labels added for EN/FR.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ dist/
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
meta.json
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Palette, Sun, Moon, Monitor, Check } from 'lucide-react';
|
||||
import { Palette, Sun, Moon, Monitor, Laptop, Check } from 'lucide-react';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import type { ThemeName, AccentColor } from '../contexts/ThemeContextDef';
|
||||
import { useT } from '../hooks/useLocale';
|
||||
@@ -7,6 +7,7 @@ import { useT } from '../hooks/useLocale';
|
||||
import type { TranslationKey } from '../lib/i18n';
|
||||
|
||||
const themeOptions: { value: ThemeName; icon: typeof Sun; labelKey: TranslationKey }[] = [
|
||||
{ value: 'system', icon: Laptop, labelKey: 'theme.system' },
|
||||
{ value: 'dark', icon: Moon, labelKey: 'theme.dark' },
|
||||
{ value: 'light', icon: Sun, labelKey: 'theme.light' },
|
||||
{ value: 'oled', icon: Monitor, labelKey: 'theme.oled' },
|
||||
|
||||
@@ -10,7 +10,8 @@ interface StoredTheme {
|
||||
accent: AccentColor;
|
||||
}
|
||||
|
||||
const themes: Record<ThemeName, Record<string, string>> = {
|
||||
type ConcreteTheme = 'dark' | 'light' | 'oled';
|
||||
const themes: Record<ConcreteTheme, Record<string, string>> = {
|
||||
dark: {
|
||||
'--pc-bg-base': '#1e1e24',
|
||||
'--pc-bg-surface': '#232329',
|
||||
@@ -131,12 +132,20 @@ function applyVars(vars: Record<string, string>) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve 'system' to the actual theme based on OS preference. */
|
||||
function resolveTheme(name: ThemeName): 'dark' | 'light' | 'oled' {
|
||||
if (name === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function loadStored(): StoredTheme {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.theme in themes && parsed.accent in accents) return parsed;
|
||||
if ((parsed.theme in themes || parsed.theme === 'system') && parsed.accent in accents) return parsed;
|
||||
}
|
||||
} catch { /* ignore invalid stored JSON */ }
|
||||
return { theme: 'dark', accent: 'cyan' };
|
||||
@@ -153,7 +162,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const setTheme = useCallback((t: ThemeName) => {
|
||||
setThemeState(t);
|
||||
applyVars(themes[t]);
|
||||
applyVars(themes[resolveTheme(t)]);
|
||||
persist(t, accent);
|
||||
}, [accent, persist]);
|
||||
|
||||
@@ -165,9 +174,18 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Apply on mount
|
||||
useEffect(() => {
|
||||
applyVars({ ...themes[theme], ...accents[accent] });
|
||||
applyVars({ ...themes[resolveTheme(theme)], ...accents[accent] });
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Listen to OS color scheme changes when theme is 'system'
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
const mq = window.matchMedia('(prefers-color-scheme: light)');
|
||||
const handler = () => applyVars(themes[mq.matches ? 'light' : 'dark']);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, accent, setTheme, setAccent }}>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export type ThemeName = 'dark' | 'light' | 'oled';
|
||||
export type ThemeName = 'dark' | 'light' | 'oled' | 'system';
|
||||
export type AccentColor = 'cyan' | 'violet' | 'emerald' | 'amber' | 'rose' | 'blue';
|
||||
|
||||
export interface ThemeContextValue {
|
||||
|
||||
@@ -113,6 +113,7 @@ const en = {
|
||||
'theme.title': 'Theme',
|
||||
'theme.mode': 'Mode',
|
||||
'theme.accent': 'Accent',
|
||||
'theme.system': 'System',
|
||||
'theme.dark': 'Dark',
|
||||
'theme.light': 'Light',
|
||||
'theme.oled': 'OLED',
|
||||
@@ -218,6 +219,7 @@ const fr: Record<keyof typeof en, string> = {
|
||||
'theme.title': 'Thème',
|
||||
'theme.mode': 'Mode',
|
||||
'theme.accent': 'Accent',
|
||||
'theme.system': 'Système',
|
||||
'theme.dark': 'Sombre',
|
||||
'theme.light': 'Clair',
|
||||
'theme.oled': 'OLED',
|
||||
|
||||
Reference in New Issue
Block a user