feat: add theme context with light and dark modes
Browse filesTracks the active palette as a data-theme attribute on the root element so the CSS token system can switch instantly. Detects the user's prefers-color-scheme on first load and remembers later toggles in localStorage.
frontend/src/context/ThemeContext.tsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
createContext,
|
| 3 |
+
useCallback,
|
| 4 |
+
useContext,
|
| 5 |
+
useEffect,
|
| 6 |
+
useMemo,
|
| 7 |
+
useState,
|
| 8 |
+
type ReactNode,
|
| 9 |
+
} from "react";
|
| 10 |
+
|
| 11 |
+
export type ThemeMode = "light" | "dark";
|
| 12 |
+
const STORAGE_KEY = "signalmod.theme";
|
| 13 |
+
|
| 14 |
+
type ThemeValue = {
|
| 15 |
+
theme: ThemeMode;
|
| 16 |
+
setTheme: (t: ThemeMode) => void;
|
| 17 |
+
toggleTheme: () => void;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
const ThemeContext = createContext<ThemeValue | null>(null);
|
| 21 |
+
|
| 22 |
+
function detectInitialTheme(): ThemeMode {
|
| 23 |
+
if (typeof window === "undefined") return "light";
|
| 24 |
+
const stored = window.localStorage.getItem(STORAGE_KEY);
|
| 25 |
+
if (stored === "light" || stored === "dark") return stored;
|
| 26 |
+
const prefersDark =
|
| 27 |
+
window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
| 28 |
+
return prefersDark ? "dark" : "light";
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
| 32 |
+
const [theme, setThemeState] = useState<ThemeMode>(() => detectInitialTheme());
|
| 33 |
+
|
| 34 |
+
useEffect(() => {
|
| 35 |
+
if (typeof document !== "undefined") {
|
| 36 |
+
document.documentElement.dataset.theme = theme;
|
| 37 |
+
}
|
| 38 |
+
if (typeof window !== "undefined") {
|
| 39 |
+
window.localStorage.setItem(STORAGE_KEY, theme);
|
| 40 |
+
}
|
| 41 |
+
}, [theme]);
|
| 42 |
+
|
| 43 |
+
const setTheme = useCallback((t: ThemeMode) => setThemeState(t), []);
|
| 44 |
+
const toggleTheme = useCallback(
|
| 45 |
+
() => setThemeState((prev) => (prev === "light" ? "dark" : "light")),
|
| 46 |
+
[]
|
| 47 |
+
);
|
| 48 |
+
|
| 49 |
+
const value = useMemo<ThemeValue>(
|
| 50 |
+
() => ({ theme, setTheme, toggleTheme }),
|
| 51 |
+
[theme, setTheme, toggleTheme]
|
| 52 |
+
);
|
| 53 |
+
|
| 54 |
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export function useTheme() {
|
| 58 |
+
const ctx = useContext(ThemeContext);
|
| 59 |
+
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
| 60 |
+
return ctx;
|
| 61 |
+
}
|