ThemeContext.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
  2. import { api } from '../api/client';
  3. import { useAuth } from './AuthContext';
  4. type ThemeMode = 'light' | 'dark' | 'system';
  5. type ThemeStyle = 'classic' | 'glow' | 'vibrant';
  6. type DarkBackground = 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
  7. type LightBackground = 'neutral' | 'warm' | 'cool';
  8. type ThemeAccent = 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
  9. interface ThemeContextType {
  10. mode: ThemeMode;
  11. resolvedMode: 'light' | 'dark';
  12. // Dark mode settings
  13. darkStyle: ThemeStyle;
  14. darkBackground: DarkBackground;
  15. darkAccent: ThemeAccent;
  16. // Light mode settings
  17. lightStyle: ThemeStyle;
  18. lightBackground: LightBackground;
  19. lightAccent: ThemeAccent;
  20. // Show live print progress (% + accent-coloured ring favicon) in the browser tab
  21. progressInTitle: boolean;
  22. setProgressInTitle: (v: boolean) => void;
  23. // Actions
  24. toggleMode: () => void;
  25. setMode: (mode: ThemeMode) => void;
  26. setDarkStyle: (style: ThemeStyle) => void;
  27. setDarkBackground: (background: DarkBackground) => void;
  28. setDarkAccent: (accent: ThemeAccent) => void;
  29. setLightStyle: (style: ThemeStyle) => void;
  30. setLightBackground: (background: LightBackground) => void;
  31. setLightAccent: (accent: ThemeAccent) => void;
  32. }
  33. const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
  34. export function ThemeProvider({ children }: { children: ReactNode }) {
  35. // Auth-aware: read state from AuthContext so the initial getSettings()
  36. // sync waits until we know whether (a) auth is enabled and (b) there
  37. // is a logged-in user. Without this the fetch fires on the login page
  38. // and returns 401 — harmless (the catch swallows it) but noisy in the
  39. // network panel and wasteful. ThemeProvider is mounted inside
  40. // AuthProvider in App.tsx specifically so this hook is callable.
  41. const { authEnabled, user, loading: authLoading } = useAuth();
  42. // Mode
  43. const [mode, setModeState] = useState<ThemeMode>(() => {
  44. const stored = localStorage.getItem('theme-mode') as ThemeMode | null;
  45. const legacy = localStorage.getItem('theme') as ThemeMode | null;
  46. return stored || legacy || 'dark';
  47. });
  48. // System preference detection
  49. const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(() => {
  50. return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  51. });
  52. useEffect(() => {
  53. const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
  54. const handler = (e: MediaQueryListEvent) => {
  55. setSystemPreference(e.matches ? 'dark' : 'light');
  56. };
  57. mediaQuery.addEventListener('change', handler);
  58. return () => mediaQuery.removeEventListener('change', handler);
  59. }, []);
  60. // Resolved mode: what's actually applied (always 'light' or 'dark')
  61. const resolvedMode: 'light' | 'dark' = mode === 'system' ? systemPreference : mode;
  62. // Dark mode settings
  63. const [darkStyle, setDarkStyleState] = useState<ThemeStyle>(() => {
  64. return (localStorage.getItem('dark-style') as ThemeStyle) || 'vibrant';
  65. });
  66. const [darkBackground, setDarkBackgroundState] = useState<DarkBackground>(() => {
  67. return (localStorage.getItem('dark-background') as DarkBackground) || 'cool';
  68. });
  69. const [darkAccent, setDarkAccentState] = useState<ThemeAccent>(() => {
  70. return (localStorage.getItem('dark-accent') as ThemeAccent) || 'green';
  71. });
  72. // Light mode settings
  73. const [lightStyle, setLightStyleState] = useState<ThemeStyle>(() => {
  74. return (localStorage.getItem('light-style') as ThemeStyle) || 'classic';
  75. });
  76. const [lightBackground, setLightBackgroundState] = useState<LightBackground>(() => {
  77. return (localStorage.getItem('light-background') as LightBackground) || 'neutral';
  78. });
  79. const [lightAccent, setLightAccentState] = useState<ThemeAccent>(() => {
  80. return (localStorage.getItem('light-accent') as ThemeAccent) || 'green';
  81. });
  82. // Client-only pref (localStorage), no api.updateSettings sync — the tab
  83. // title/favicon is per-browser behaviour. Move to server settings if it
  84. // ever needs to follow the user across devices. Default off.
  85. const [progressInTitle, setProgressInTitleState] = useState<boolean>(() => {
  86. return localStorage.getItem('progress-in-title') === 'true';
  87. });
  88. const setProgressInTitle = (v: boolean) => {
  89. setProgressInTitleState(v);
  90. localStorage.setItem('progress-in-title', String(v));
  91. };
  92. // Sync from API once auth state is known. Same gate shape as
  93. // useStreamTokenSync / ColorCatalogProvider: wait for AuthContext to
  94. // settle, then only fetch when we can actually expect a 200 (auth
  95. // disabled, or auth enabled with a logged-in user).
  96. useEffect(() => {
  97. if (authLoading) return;
  98. if (authEnabled && user === null) return;
  99. api.getSettings().then((settings) => {
  100. // Dark settings
  101. if (settings.dark_style) {
  102. setDarkStyleState(settings.dark_style as ThemeStyle);
  103. localStorage.setItem('dark-style', settings.dark_style);
  104. }
  105. if (settings.dark_background) {
  106. setDarkBackgroundState(settings.dark_background as DarkBackground);
  107. localStorage.setItem('dark-background', settings.dark_background);
  108. }
  109. if (settings.dark_accent) {
  110. setDarkAccentState(settings.dark_accent as ThemeAccent);
  111. localStorage.setItem('dark-accent', settings.dark_accent);
  112. }
  113. // Light settings
  114. if (settings.light_style) {
  115. setLightStyleState(settings.light_style as ThemeStyle);
  116. localStorage.setItem('light-style', settings.light_style);
  117. }
  118. if (settings.light_background) {
  119. setLightBackgroundState(settings.light_background as LightBackground);
  120. localStorage.setItem('light-background', settings.light_background);
  121. }
  122. if (settings.light_accent) {
  123. setLightAccentState(settings.light_accent as ThemeAccent);
  124. localStorage.setItem('light-accent', settings.light_accent);
  125. }
  126. }).catch(() => {});
  127. // Re-fetch when auth state transitions (e.g. login completes); the
  128. // gate above short-circuits subsequent calls once we already have a
  129. // valid sync.
  130. }, [authLoading, authEnabled, user]);
  131. // Apply theme classes based on current mode
  132. useEffect(() => {
  133. const root = document.documentElement;
  134. // Remove all theme classes
  135. root.classList.remove(
  136. 'dark',
  137. 'style-classic', 'style-glow', 'style-vibrant',
  138. 'bg-neutral', 'bg-warm', 'bg-cool', 'bg-oled', 'bg-slate', 'bg-forest',
  139. 'accent-green', 'accent-teal', 'accent-blue', 'accent-orange', 'accent-purple', 'accent-red'
  140. );
  141. // Apply based on resolved mode
  142. if (resolvedMode === 'dark') {
  143. root.classList.add('dark');
  144. root.classList.add(`style-${darkStyle}`);
  145. root.classList.add(`bg-${darkBackground}`);
  146. root.classList.add(`accent-${darkAccent}`);
  147. } else {
  148. root.classList.add(`style-${lightStyle}`);
  149. root.classList.add(`bg-${lightBackground}`);
  150. root.classList.add(`accent-${lightAccent}`);
  151. }
  152. localStorage.setItem('theme-mode', mode);
  153. localStorage.removeItem('theme');
  154. }, [mode, resolvedMode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent]);
  155. const toggleMode = () => setModeState(prev => {
  156. if (prev === 'dark') return 'light';
  157. if (prev === 'light') return 'system';
  158. return 'dark';
  159. });
  160. const setMode = (m: ThemeMode) => setModeState(m);
  161. // Dark setters
  162. const setDarkStyle = (v: ThemeStyle) => {
  163. setDarkStyleState(v);
  164. localStorage.setItem('dark-style', v);
  165. api.updateSettings({ dark_style: v }).catch(() => {});
  166. };
  167. const setDarkBackground = (v: DarkBackground) => {
  168. setDarkBackgroundState(v);
  169. localStorage.setItem('dark-background', v);
  170. api.updateSettings({ dark_background: v }).catch(() => {});
  171. };
  172. const setDarkAccent = (v: ThemeAccent) => {
  173. setDarkAccentState(v);
  174. localStorage.setItem('dark-accent', v);
  175. api.updateSettings({ dark_accent: v }).catch(() => {});
  176. };
  177. // Light setters
  178. const setLightStyle = (v: ThemeStyle) => {
  179. setLightStyleState(v);
  180. localStorage.setItem('light-style', v);
  181. api.updateSettings({ light_style: v }).catch(() => {});
  182. };
  183. const setLightBackground = (v: LightBackground) => {
  184. setLightBackgroundState(v);
  185. localStorage.setItem('light-background', v);
  186. api.updateSettings({ light_background: v }).catch(() => {});
  187. };
  188. const setLightAccent = (v: ThemeAccent) => {
  189. setLightAccentState(v);
  190. localStorage.setItem('light-accent', v);
  191. api.updateSettings({ light_accent: v }).catch(() => {});
  192. };
  193. return (
  194. <ThemeContext.Provider value={{
  195. mode,
  196. resolvedMode,
  197. darkStyle, darkBackground, darkAccent,
  198. lightStyle, lightBackground, lightAccent,
  199. progressInTitle, setProgressInTitle,
  200. toggleMode, setMode,
  201. setDarkStyle, setDarkBackground, setDarkAccent,
  202. setLightStyle, setLightBackground, setLightAccent,
  203. }}>
  204. {children}
  205. </ThemeContext.Provider>
  206. );
  207. }
  208. export function useTheme() {
  209. const context = useContext(ThemeContext);
  210. if (!context) throw new Error('useTheme must be used within ThemeProvider');
  211. return context;
  212. }
  213. export type { ThemeMode, ThemeStyle, DarkBackground, LightBackground, ThemeAccent };