155 lines
4.9 KiB
TypeScript
155 lines
4.9 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useState,
|
|
} from "react";
|
|
import type { ReactNode } from "react";
|
|
|
|
/**
|
|
* App theme provider (replaces next-themes).
|
|
*
|
|
* next-themes injects its no-FOUC script as a <script> inside the React
|
|
* component tree, which React 19 (dev) rejects: "Encountered a script tag
|
|
* while rendering React component". Everything it does is small, so this
|
|
* provider implements the same API ({ theme, setTheme, resolvedTheme })
|
|
* directly, and the equivalent inline script lives in app/layout.tsx where
|
|
* it renders as plain server HTML and actually executes.
|
|
*
|
|
* Semantics (kept compatible for all existing consumers):
|
|
* - `theme` the stored preference: default | sunset | dark | ocean | system
|
|
* - `resolvedTheme` the theme actually applied (system resolved via
|
|
* prefers-color-scheme), always one of the four
|
|
* - html classes .theme-default | .theme-sunset | .dark | .ocean (see globals.css)
|
|
*/
|
|
|
|
export const THEMES = ["default", "sunset", "dark", "ocean"] as const;
|
|
export type ThemeName = (typeof THEMES)[number];
|
|
export type RawTheme = ThemeName | "system";
|
|
|
|
const STORAGE_KEY = "theme";
|
|
const THEME_CLASSES: Record<ThemeName, string> = {
|
|
default: "theme-default",
|
|
sunset: "theme-sunset",
|
|
dark: "dark",
|
|
ocean: "ocean",
|
|
};
|
|
const ALL_CLASSES = Object.values(THEME_CLASSES);
|
|
const DARK_SURFACES: ThemeName[] = ["dark", "ocean"];
|
|
|
|
interface ThemeContextValue {
|
|
theme: RawTheme;
|
|
resolvedTheme: ThemeName;
|
|
setTheme: (theme: RawTheme) => void;
|
|
themes: readonly ThemeName[];
|
|
}
|
|
|
|
const ThemeContext = createContext<ThemeContextValue>({
|
|
theme: "system",
|
|
resolvedTheme: "default",
|
|
setTheme: () => {},
|
|
themes: THEMES,
|
|
});
|
|
|
|
function systemPrefersDark(): boolean {
|
|
return (
|
|
typeof window !== "undefined" &&
|
|
!!window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
|
);
|
|
}
|
|
|
|
function resolve(theme: RawTheme): ThemeName {
|
|
return theme === "system" ? (systemPrefersDark() ? "dark" : "default") : theme;
|
|
}
|
|
|
|
/** Exported so consumers (e.g. isDarkTheme) derive the applied theme from
|
|
* the same source of truth the provider uses -- and stay reactive when it
|
|
* changes. */
|
|
export function resolveTheme(theme: RawTheme): ThemeName {
|
|
return resolve(theme);
|
|
}
|
|
|
|
function applyToDom(theme: RawTheme) {
|
|
if (typeof document === "undefined") return;
|
|
const name = resolve(theme);
|
|
const root = document.documentElement;
|
|
// Also drop "light": a class next-themes applied to <html> in earlier
|
|
// versions, harmless but stale.
|
|
root.classList.remove(...ALL_CLASSES, "light");
|
|
root.classList.add(THEME_CLASSES[name]);
|
|
root.style.colorScheme = DARK_SURFACES.includes(name) ? "dark" : "light";
|
|
}
|
|
|
|
export function ThemeProvider({
|
|
children,
|
|
defaultTheme = "system",
|
|
}: {
|
|
children: ReactNode;
|
|
defaultTheme?: RawTheme;
|
|
}) {
|
|
// Both the server and the first client render agree on `defaultTheme`
|
|
// (hydration-safe); the stored preference is picked up right after mount.
|
|
// The inline script in app/layout.tsx already applied the right classes
|
|
// before first paint, so there is no visible jump either way.
|
|
const [theme, setThemeState] = useState<RawTheme>(defaultTheme);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
if (stored) setThemeState(stored as RawTheme);
|
|
} catch {
|
|
/* storage unavailable -- stay on defaultTheme */
|
|
}
|
|
}, []);
|
|
|
|
// Keep the DOM in sync whenever the preference changes -- synchronously
|
|
// (layout effect) so the CSS classes land in the same frame as the
|
|
// JS-driven colors that derive from this state: no flash of stale colors.
|
|
useLayoutEffect(() => {
|
|
applyToDom(theme);
|
|
}, [theme]);
|
|
|
|
// Follow the OS while in "system" mode.
|
|
useEffect(() => {
|
|
if (theme !== "system" || !window.matchMedia) return;
|
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
const onChange = () => applyToDom("system");
|
|
mq.addEventListener("change", onChange);
|
|
return () => mq.removeEventListener("change", onChange);
|
|
}, [theme]);
|
|
|
|
// Keep multiple tabs in sync.
|
|
useEffect(() => {
|
|
const onStorage = (e: StorageEvent) => {
|
|
if (e.key === STORAGE_KEY) setThemeState((e.newValue || defaultTheme) as RawTheme);
|
|
};
|
|
window.addEventListener("storage", onStorage);
|
|
return () => window.removeEventListener("storage", onStorage);
|
|
}, [defaultTheme]);
|
|
|
|
const setTheme = useCallback((t: RawTheme) => {
|
|
setThemeState(t);
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, t);
|
|
} catch {
|
|
/* ignore -- preference just won't persist */
|
|
}
|
|
}, []);
|
|
|
|
const value = useMemo<ThemeContextValue>(
|
|
() => ({ theme, resolvedTheme: resolve(theme), setTheme, themes: THEMES }),
|
|
[theme, setTheme],
|
|
);
|
|
|
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
}
|
|
|
|
export function useTheme(): ThemeContextValue {
|
|
return useContext(ThemeContext);
|
|
}
|