| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235 |
- import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
- import { api } from '../api/client';
- import { useAuth } from './AuthContext';
- type ThemeMode = 'light' | 'dark' | 'system';
- type ThemeStyle = 'classic' | 'glow' | 'vibrant';
- type DarkBackground = 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
- type LightBackground = 'neutral' | 'warm' | 'cool';
- type ThemeAccent = 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
- interface ThemeContextType {
- mode: ThemeMode;
- resolvedMode: 'light' | 'dark';
- // Dark mode settings
- darkStyle: ThemeStyle;
- darkBackground: DarkBackground;
- darkAccent: ThemeAccent;
- // Light mode settings
- lightStyle: ThemeStyle;
- lightBackground: LightBackground;
- lightAccent: ThemeAccent;
- // Show live print progress (% + accent-coloured ring favicon) in the browser tab
- progressInTitle: boolean;
- setProgressInTitle: (v: boolean) => void;
- // Actions
- toggleMode: () => void;
- setMode: (mode: ThemeMode) => void;
- setDarkStyle: (style: ThemeStyle) => void;
- setDarkBackground: (background: DarkBackground) => void;
- setDarkAccent: (accent: ThemeAccent) => void;
- setLightStyle: (style: ThemeStyle) => void;
- setLightBackground: (background: LightBackground) => void;
- setLightAccent: (accent: ThemeAccent) => void;
- }
- const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
- export function ThemeProvider({ children }: { children: ReactNode }) {
- // Auth-aware: read state from AuthContext so the initial getSettings()
- // sync waits until we know whether (a) auth is enabled and (b) there
- // is a logged-in user. Without this the fetch fires on the login page
- // and returns 401 — harmless (the catch swallows it) but noisy in the
- // network panel and wasteful. ThemeProvider is mounted inside
- // AuthProvider in App.tsx specifically so this hook is callable.
- const { authEnabled, user, loading: authLoading } = useAuth();
- // Mode
- const [mode, setModeState] = useState<ThemeMode>(() => {
- const stored = localStorage.getItem('theme-mode') as ThemeMode | null;
- const legacy = localStorage.getItem('theme') as ThemeMode | null;
- 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';
- });
- const [darkBackground, setDarkBackgroundState] = useState<DarkBackground>(() => {
- return (localStorage.getItem('dark-background') as DarkBackground) || 'cool';
- });
- const [darkAccent, setDarkAccentState] = useState<ThemeAccent>(() => {
- return (localStorage.getItem('dark-accent') as ThemeAccent) || 'green';
- });
- // Light mode settings
- const [lightStyle, setLightStyleState] = useState<ThemeStyle>(() => {
- return (localStorage.getItem('light-style') as ThemeStyle) || 'classic';
- });
- const [lightBackground, setLightBackgroundState] = useState<LightBackground>(() => {
- return (localStorage.getItem('light-background') as LightBackground) || 'neutral';
- });
- const [lightAccent, setLightAccentState] = useState<ThemeAccent>(() => {
- 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<boolean>(() => {
- 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
- // disabled, or auth enabled with a logged-in user).
- useEffect(() => {
- if (authLoading) return;
- if (authEnabled && user === null) return;
- api.getSettings().then((settings) => {
- // Dark settings
- if (settings.dark_style) {
- setDarkStyleState(settings.dark_style as ThemeStyle);
- localStorage.setItem('dark-style', settings.dark_style);
- }
- if (settings.dark_background) {
- setDarkBackgroundState(settings.dark_background as DarkBackground);
- localStorage.setItem('dark-background', settings.dark_background);
- }
- if (settings.dark_accent) {
- setDarkAccentState(settings.dark_accent as ThemeAccent);
- localStorage.setItem('dark-accent', settings.dark_accent);
- }
- // Light settings
- if (settings.light_style) {
- setLightStyleState(settings.light_style as ThemeStyle);
- localStorage.setItem('light-style', settings.light_style);
- }
- if (settings.light_background) {
- setLightBackgroundState(settings.light_background as LightBackground);
- localStorage.setItem('light-background', settings.light_background);
- }
- if (settings.light_accent) {
- setLightAccentState(settings.light_accent as ThemeAccent);
- localStorage.setItem('light-accent', settings.light_accent);
- }
- }).catch(() => {});
- // Re-fetch when auth state transitions (e.g. login completes); the
- // gate above short-circuits subsequent calls once we already have a
- // valid sync.
- }, [authLoading, authEnabled, user]);
- // Apply theme classes based on current mode
- useEffect(() => {
- const root = document.documentElement;
- // Remove all theme classes
- root.classList.remove(
- 'dark',
- 'style-classic', 'style-glow', 'style-vibrant',
- 'bg-neutral', 'bg-warm', 'bg-cool', 'bg-oled', 'bg-slate', 'bg-forest',
- 'accent-green', 'accent-teal', 'accent-blue', 'accent-orange', 'accent-purple', 'accent-red'
- );
- // Apply based on resolved mode
- if (resolvedMode === 'dark') {
- root.classList.add('dark');
- root.classList.add(`style-${darkStyle}`);
- root.classList.add(`bg-${darkBackground}`);
- root.classList.add(`accent-${darkAccent}`);
- } else {
- root.classList.add(`style-${lightStyle}`);
- root.classList.add(`bg-${lightBackground}`);
- root.classList.add(`accent-${lightAccent}`);
- }
- localStorage.setItem('theme-mode', mode);
- localStorage.removeItem('theme');
- }, [mode, resolvedMode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent]);
- const toggleMode = () => setModeState(prev => {
- if (prev === 'dark') return 'light';
- if (prev === 'light') return 'system';
- return 'dark';
- });
- const setMode = (m: ThemeMode) => setModeState(m);
- // Dark setters
- const setDarkStyle = (v: ThemeStyle) => {
- setDarkStyleState(v);
- localStorage.setItem('dark-style', v);
- api.updateSettings({ dark_style: v }).catch(() => {});
- };
- const setDarkBackground = (v: DarkBackground) => {
- setDarkBackgroundState(v);
- localStorage.setItem('dark-background', v);
- api.updateSettings({ dark_background: v }).catch(() => {});
- };
- const setDarkAccent = (v: ThemeAccent) => {
- setDarkAccentState(v);
- localStorage.setItem('dark-accent', v);
- api.updateSettings({ dark_accent: v }).catch(() => {});
- };
- // Light setters
- const setLightStyle = (v: ThemeStyle) => {
- setLightStyleState(v);
- localStorage.setItem('light-style', v);
- api.updateSettings({ light_style: v }).catch(() => {});
- };
- const setLightBackground = (v: LightBackground) => {
- setLightBackgroundState(v);
- localStorage.setItem('light-background', v);
- api.updateSettings({ light_background: v }).catch(() => {});
- };
- const setLightAccent = (v: ThemeAccent) => {
- setLightAccentState(v);
- localStorage.setItem('light-accent', v);
- api.updateSettings({ light_accent: v }).catch(() => {});
- };
- return (
- <ThemeContext.Provider value={{
- mode,
- resolvedMode,
- darkStyle, darkBackground, darkAccent,
- lightStyle, lightBackground, lightAccent,
- progressInTitle, setProgressInTitle,
- toggleMode, setMode,
- setDarkStyle, setDarkBackground, setDarkAccent,
- setLightStyle, setLightBackground, setLightAccent,
- }}>
- {children}
- </ThemeContext.Provider>
- );
- }
- export function useTheme() {
- const context = useContext(ThemeContext);
- if (!context) throw new Error('useTheme must be used within ThemeProvider');
- return context;
- }
- export type { ThemeMode, ThemeStyle, DarkBackground, LightBackground, ThemeAccent };
|