Organize/components/theme/theme-provider.tsx

257 lines
9.3 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import type { ReactNode } from "react";
import {
THEMES,
THEME_CLASSES,
DARK_SURFACES,
themeColorScheme,
type ThemeName,
type RawTheme,
} from "@/lib/themes";
import { saveUserTheme } from "@/lib/actions/profile";
// Re-exported so existing imports keep working.
export { THEMES, DARK_SURFACES };
export type { ThemeName, RawTheme };
/**
* 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, always one of THEMES
* - html classes .theme-default | .theme-sunset | ... (see globals.css)
*
* Theme scoping (per-project themes):
* A subtree can temporarily override the global preference with
* `useThemeScope().setScope(name)` -- the project pages use this so a
* project's assigned theme applies while its page is open and lifts on
* navigation (see components/theme/project-theme-scope.tsx). While a scope
* is active it wins over `theme` for `resolvedTheme` and the DOM; the
* stored global preference is never touched, so changing the global theme
* from the theme menu while inside a scoped project is fine (it applies
* everywhere *except* the scoped page, which keeps its own theme).
* The provider also picks up a `data-project-theme` marker on <html> at
* mount -- set by the project page's no-FOUC inline script -- so a scoped
* theme is in effect from the very first frame of a hard page load.
*
* Per-user themes (per-account, survive account toggles):
* The root layout passes `userTheme` -- the signed-in user's preference
* from the profile row (saveUserTheme action) -- instead of defaulting
* to localStorage. That stored value is the initial state and the
* no-FOUC script applies it before first paint, and setTheme() persists
* changes back to the profile. Anonymous visitors keep the classic
* localStorage-only behavior.
*/
const STORAGE_KEY = "theme";
const ALL_CLASSES = Object.values(THEME_CLASSES);
interface ThemeContextValue {
theme: RawTheme;
resolvedTheme: ThemeName;
setTheme: (theme: RawTheme) => void;
themes: readonly ThemeName[];
}
interface ThemeScopeValue {
scope: ThemeName | null;
setScope: (scope: ThemeName | null) => void;
}
const ThemeContext = createContext<ThemeContextValue>({
theme: "system",
resolvedTheme: "default",
setTheme: () => {},
themes: THEMES,
});
const ThemeScopeContext = createContext<ThemeScopeValue>({
scope: null,
setScope: () => {},
});
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(name: ThemeName) {
if (typeof document === "undefined") return;
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 = themeColorScheme(name);
}
/** The project page's no-FOUC script leaves this marker behind (see
* themeSwitchScript in lib/themes.ts); read it once at mount so the first
* frame of a hard load is already scoped. */
function readInitialScope(): ThemeName | null {
if (typeof document === "undefined") return null;
const marker = document.documentElement.dataset.projectTheme;
return marker && (THEMES as readonly string[]).includes(marker)
? (marker as ThemeName)
: null;
}
export function ThemeProvider({
children,
defaultTheme = "system",
userTheme,
}: {
children: ReactNode;
defaultTheme?: RawTheme;
/**
* The signed-in user's stored theme preference -- the root layout reads
* it from the profile row (and bakes it into the no-FOUC script). When
* defined it is the source of truth: the provider starts from it instead
* of localStorage, and every setTheme() call is persisted to the profile
* (saveUserTheme) so each linked account keeps its own look after an
* account toggle. When undefined (anonymous visitor) the provider keeps
* the old localStorage-only behavior.
*/
userTheme?: RawTheme;
}) {
// Both the server and the first client render agree on the initial theme
// (hydration-safe); anonymous users' localStorage 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>(userTheme ?? defaultTheme);
// Starts null (matching the server render); the project-page marker is
// applied in a layout effect below, before paint.
const [scope, setScope] = useState<ThemeName | null>(null);
const resolvedTheme: ThemeName = scope ?? resolve(theme);
useEffect(() => {
// Signed-in users: their profile's stored theme (userTheme) is
// authoritative -- it is already the initial state, and it's what the
// no-FOUC script applied, so localStorage is deliberately not
// consulted (a stale entry from another account must not win).
if (userTheme !== undefined) return;
try {
const stored = localStorage.getItem(STORAGE_KEY);
// eslint-disable-next-line react-hooks/set-state-in-effect -- mount-time sync from localStorage (external source), same pattern as the project-theme marker effect below
if (stored) setThemeState(stored as RawTheme);
} catch {
/* storage unavailable -- stay on defaultTheme */
}
}, [userTheme]);
// Pick up a project's no-FOUC marker (hard page load). Runs in the same
// pre-paint window as the apply effect below, so the global-theme flash
// it may cause is never visible. (Mount-time sync from an external
// source, same pattern as the localStorage read above.)
useLayoutEffect(() => {
const initialScope = readInitialScope();
if (initialScope) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- mount-time sync from the <html> marker, see above
setScope(initialScope);
}
}, []);
// Keep the DOM in sync whenever the resolved theme 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(resolvedTheme);
}, [resolvedTheme]);
// Follow the OS while in "system" mode (and nothing is scoped).
useEffect(() => {
if (scope || theme !== "system" || !window.matchMedia) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyToDom(resolve("system"));
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [scope, 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 */
}
// Signed-in: persist to the profile row so the next load -- a hard
// navigation, an account toggle, another device -- applies this
// account's theme. Fire-and-forget: the UI already reflects the
// change; a failed write just means the next load falls back to the
// previously stored value.
if (userTheme !== undefined) {
void saveUserTheme(t).catch(() => {
/* best effort -- in-memory + localStorage state still stands */
});
}
},
[userTheme]
);
const value = useMemo<ThemeContextValue>(
() => ({ theme, resolvedTheme, setTheme, themes: THEMES }),
[theme, resolvedTheme, setTheme],
);
const scopeValue = useMemo<ThemeScopeValue>(
() => ({ scope, setScope }),
[scope],
);
return (
<ThemeScopeContext.Provider value={scopeValue}>
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
</ThemeScopeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
return useContext(ThemeContext);
}
export function useThemeScope(): ThemeScopeValue {
return useContext(ThemeScopeContext);
}