Files
EvoScientist-WebUI/src/providers/ThemeProvider.tsx
T
m4 14b246f4e7
CI / Format, lint & build (push) Has been cancelled
fix: stabilize chat stream updates
2026-07-10 16:26:46 +08:00

90 lines
2.7 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { Toaster } from "sonner";
import {
DARK_QUERY,
getStoredTheme,
THEME_STORAGE_KEY,
type ResolvedTheme,
type Theme,
} from "@/lib/theme";
interface ThemeContextValue {
/** The user's selection: light, dark, or follow-system. */
theme: Theme;
/** The actually-applied theme after resolving "system". */
resolvedTheme: ResolvedTheme;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function applyTheme(resolved: ResolvedTheme) {
const root = document.documentElement;
root.classList.toggle("dark", resolved === "dark");
root.style.colorScheme = resolved;
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
// Lazy initializer reads the stored choice on the client's first render, so the
// single effect below resolves the right theme on its first run — no
// "system → stored" double-apply flash. On the server it returns "system" (no
// localStorage); the inline pre-paint script in layout.tsx has already set the
// class, so nothing flashes regardless.
const [theme, setThemeState] = useState<Theme>(getStoredTheme);
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>("light");
useEffect(() => {
const mql = window.matchMedia(DARK_QUERY);
const apply = () => {
const resolved: ResolvedTheme =
theme === "system" ? (mql.matches ? "dark" : "light") : theme;
setResolvedTheme((current) =>
current === resolved ? current : resolved
);
applyTheme(resolved);
};
apply();
// Track live OS changes only while following the system.
if (theme !== "system") return;
mql.addEventListener("change", apply);
return () => mql.removeEventListener("change", apply);
}, [theme]);
const setTheme = useCallback((next: Theme) => {
try {
localStorage.setItem(THEME_STORAGE_KEY, next);
} catch {
// Private mode / storage disabled — fall back to in-memory only.
}
setThemeState((current) => (current === next ? current : next));
}, []);
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return ctx;
}
/** Sonner toaster that follows the resolved theme (must render inside ThemeProvider). */
export function ThemedToaster() {
const { resolvedTheme } = useTheme();
return <Toaster theme={resolvedTheme} />;
}