204 lines
5.8 KiB
TypeScript
204 lines
5.8 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: one of THEMES, or "system"
|
|
* - `resolvedTheme` the theme actually applied (system resolved via
|
|
* prefers-color-scheme), always one of THEMES
|
|
* - html classes .theme-default | .theme-sunset | ... (see globals.css)
|
|
*/
|
|
|
|
/* Order is the picker order: all the light looks, then the dark looks. */
|
|
export const THEMES = [
|
|
"default",
|
|
"sunset",
|
|
"meadow",
|
|
"honey",
|
|
"rose",
|
|
"lavender",
|
|
"slate",
|
|
"blueprint",
|
|
"vaporwave",
|
|
"notebook",
|
|
"dark",
|
|
"ocean",
|
|
"pine",
|
|
"plum",
|
|
"midnight",
|
|
"ember",
|
|
"rosewood",
|
|
"cyberpunk",
|
|
"starfield",
|
|
] 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",
|
|
meadow: "theme-meadow",
|
|
honey: "theme-honey",
|
|
rose: "theme-rose",
|
|
lavender: "theme-lavender",
|
|
slate: "theme-slate",
|
|
blueprint: "theme-blueprint",
|
|
vaporwave: "theme-vaporwave",
|
|
notebook: "theme-notebook",
|
|
dark: "dark",
|
|
ocean: "ocean",
|
|
pine: "theme-pine",
|
|
plum: "theme-plum",
|
|
midnight: "theme-midnight",
|
|
ember: "theme-ember",
|
|
rosewood: "theme-rosewood",
|
|
cyberpunk: "theme-cyberpunk",
|
|
starfield: "theme-starfield",
|
|
};
|
|
const ALL_CLASSES = Object.values(THEME_CLASSES);
|
|
/* Themes whose *surfaces* are dark (see app/globals.css). The single source
|
|
* of truth for every dark-surface check: isDarkTheme, the sonner toaster,
|
|
* color-scheme, and the `dark:` custom-variant in globals.css. */
|
|
export const DARK_SURFACES: ThemeName[] = [
|
|
"dark",
|
|
"ocean",
|
|
"pine",
|
|
"plum",
|
|
"midnight",
|
|
"ember",
|
|
"rosewood",
|
|
"cyberpunk",
|
|
"starfield",
|
|
];
|
|
|
|
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);
|
|
}
|