import type { ThemePreference } from "@batch-cooking/shared"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react"; import { apiClient } from "../../api/client"; import { useAuth } from "../auth/AuthContext"; /** Shape of the theme state/actions exposed via {@link useTheme}. */ interface ThemeContextValue { /** The signed-in user's theme preference — `"SYSTEM"` (the default) for a logged-out visitor too, nothing to load a preference for. */ theme: ThemePreference; /** Persists `theme` and applies it immediately. Throws `ApiError` on failure. */ setTheme: (theme: ThemePreference) => Promise; } /** React context carrying {@link ThemeContextValue} — always accessed through {@link useTheme}, never directly. */ const ThemeContext = createContext(null); /** * Applies `theme` to the document. `SYSTEM` *removes* the override rather * than setting a literal `"system"` value `_theme.scss` wouldn't recognize * — with no `data-theme` attribute at all, its `prefers-color-scheme` * media query decides instead, exactly the "system" behavior. */ function applyTheme(theme: ThemePreference) { if (theme === "SYSTEM") { delete document.documentElement.dataset.theme; } else { document.documentElement.dataset.theme = theme.toLowerCase(); } } /** * Provides the signed-in user's theme preference and keeps the document in * sync with it. Must be nested inside `AuthProvider` (reads `useAuth()`'s * `user` to know when a profile is available to load a preference for) — * see `main.tsx`. * * Keyed on `user?.id` rather than `user` itself: `AuthContext`'s `user` * object is a fresh reference every time it refreshes (e.g. after an * unrelated `refreshUser()` call elsewhere), which shouldn't re-trigger a * preferences fetch — only actually signing in/out or switching accounts * should. */ export function ThemeProvider({ children }: { children: ReactNode }) { const { user } = useAuth(); const [theme, setThemeState] = useState("SYSTEM"); // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the id on purpose, see the doc comment above. useEffect(() => { if (!user) { setThemeState("SYSTEM"); applyTheme("SYSTEM"); return; } let cancelled = false; apiClient .getPreferences() .then((preferences) => { if (cancelled) return; setThemeState(preferences.theme); applyTheme(preferences.theme); }) // Failing to load a preference (e.g. a network hiccup) shouldn't break // the rest of the app — worst case the theme just stays at its // current/default value, same "not fatal" spirit as AuthContext's own // `me()` call on mount. .catch(() => {}); return () => { cancelled = true; }; }, [user?.id]); const setTheme = useCallback(async (newTheme: ThemePreference) => { try { await apiClient.updatePreferences(newTheme); setThemeState(newTheme); applyTheme(newTheme); } catch (err) { // Rethrown as-is — the caller is what actually handles this (see this // context value's doc comment, "Throws ApiError on failure"); this // callback just isn't allowed a bare `await` per the repo's // async/try-catch convention. throw err; } }, []); return {children}; } /** Reads the current theme state/actions. Must be called from within a {@link ThemeProvider}. */ export function useTheme(): ThemeContextValue { const ctx = useContext(ThemeContext); if (!ctx) { throw new Error("useTheme must be used within a ThemeProvider"); } return ctx; }