feat(frontend): add dark mode option (#34)
Resolves #32 Reviewed-on: #34
This commit was merged in pull request #34.
This commit is contained in:
23
frontend/src/theme/ThemeContext.ts
Normal file
23
frontend/src/theme/ThemeContext.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
/** What the user chose. "system" defers to the OS setting. */
|
||||
type ThemePreference = "light" | "dark" | "system";
|
||||
|
||||
/** The theme actually on screen, once "system" has been resolved. */
|
||||
type ResolvedTheme = "light" | "dark";
|
||||
|
||||
type ThemeContextValue = {
|
||||
/** The stored choice, which may be "system". */
|
||||
preference: ThemePreference;
|
||||
/** The theme currently applied to the document. */
|
||||
theme: ResolvedTheme;
|
||||
/** Store a new preference and apply it immediately. */
|
||||
setPreference: (preference: ThemePreference) => void;
|
||||
/** Flip between light and dark, pinning the result as an explicit choice. */
|
||||
toggleTheme: () => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export { ThemeContext };
|
||||
export type { ThemeContextValue, ThemePreference, ResolvedTheme };
|
||||
71
frontend/src/theme/ThemeProvider.tsx
Normal file
71
frontend/src/theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ThemeContext } from "./ThemeContext.ts";
|
||||
import type { ResolvedTheme, ThemePreference } from "./ThemeContext.ts";
|
||||
|
||||
/** Shared with the pre-paint script in index.html — keep the two in step. */
|
||||
const STORAGE_KEY = "theme";
|
||||
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
||||
|
||||
const readPreference = (): ThemePreference => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||
return stored;
|
||||
}
|
||||
} catch {
|
||||
// Storage blocked (private browsing, cookies disabled) — follow the OS.
|
||||
}
|
||||
return "system";
|
||||
};
|
||||
|
||||
const readSystemTheme = (): ResolvedTheme =>
|
||||
window.matchMedia(DARK_QUERY).matches ? "dark" : "light";
|
||||
|
||||
/**
|
||||
* Owns the single source of truth for the active theme. Nothing reads colours
|
||||
* from here: the palette lives in index.css and switches on the `data-theme`
|
||||
* attribute this provider writes, so components keep using the semantic
|
||||
* utilities (`bg-card`, `text-text-primary`) and follow along for free.
|
||||
*/
|
||||
const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [preference, setPreferenceState] = useState<ThemePreference>(readPreference);
|
||||
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(readSystemTheme);
|
||||
|
||||
// Keep following the OS while the preference is "system".
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia(DARK_QUERY);
|
||||
const onChange = (event: MediaQueryListEvent) =>
|
||||
setSystemTheme(event.matches ? "dark" : "light");
|
||||
|
||||
query.addEventListener("change", onChange);
|
||||
return () => query.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
const theme: ResolvedTheme = preference === "system" ? systemTheme : preference;
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}, [theme]);
|
||||
|
||||
const setPreference = useCallback((next: ThemePreference) => {
|
||||
setPreferenceState(next);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Storage blocked — the choice still applies for this session.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setPreference(theme === "dark" ? "light" : "dark");
|
||||
}, [theme, setPreference]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ preference, theme, setPreference, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeProvider;
|
||||
14
frontend/src/theme/useTheme.ts
Normal file
14
frontend/src/theme/useTheme.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useContext } from "react";
|
||||
import { ThemeContext } from "./ThemeContext.ts";
|
||||
import type { ThemeContextValue } from "./ThemeContext.ts";
|
||||
|
||||
/** Access the centralised theme state. Must be used within a <ThemeProvider>. */
|
||||
const useTheme = (): ThemeContextValue => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export { useTheme };
|
||||
Reference in New Issue
Block a user