|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { setThemePreference } from "@/backend/services/theme.actions"; |
| 4 | +import * as React from "react"; |
| 5 | + |
| 6 | +import { type ThemePreference } from "@/lib/theme-cookie"; |
| 7 | + |
| 8 | +export type { ThemePreference }; |
| 9 | + |
| 10 | +function getSystemTheme(): "light" | "dark" { |
| 11 | + if (typeof window === "undefined") return "light"; |
| 12 | + return window.matchMedia("(prefers-color-scheme: dark)").matches |
| 13 | + ? "dark" |
| 14 | + : "light"; |
| 15 | +} |
| 16 | + |
| 17 | +export function resolveTheme(pref: ThemePreference): "light" | "dark" { |
| 18 | + if (pref === "system") return getSystemTheme(); |
| 19 | + return pref; |
| 20 | +} |
| 21 | + |
| 22 | +function applyToDocument(resolved: "light" | "dark") { |
| 23 | + document.documentElement.setAttribute("data-theme", resolved); |
| 24 | + document.documentElement.style.colorScheme = resolved; |
| 25 | +} |
| 26 | + |
| 27 | +export type ThemeContextValue = { |
| 28 | + theme: ThemePreference; |
| 29 | + setTheme: (t: ThemePreference) => void; |
| 30 | + resolvedTheme: "light" | "dark"; |
| 31 | +}; |
| 32 | + |
| 33 | +const ThemeContext = React.createContext<ThemeContextValue | null>(null); |
| 34 | + |
| 35 | +export function useTheme(): ThemeContextValue { |
| 36 | + const ctx = React.useContext(ThemeContext); |
| 37 | + if (!ctx) { |
| 38 | + throw new Error("useTheme must be used within ThemeProvider"); |
| 39 | + } |
| 40 | + return ctx; |
| 41 | +} |
| 42 | + |
| 43 | +type ThemeProviderProps = { |
| 44 | + children: React.ReactNode; |
| 45 | + initialTheme: ThemePreference; |
| 46 | + migrateThemeFromLocalStorage: boolean; |
| 47 | +}; |
| 48 | + |
| 49 | +export function ThemeProvider({ |
| 50 | + children, |
| 51 | + initialTheme, |
| 52 | + migrateThemeFromLocalStorage, |
| 53 | +}: ThemeProviderProps) { |
| 54 | + const [theme, setThemeState] = React.useState<ThemePreference>(initialTheme); |
| 55 | + const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">( |
| 56 | + "light", |
| 57 | + ); |
| 58 | + |
| 59 | + React.useEffect(() => { |
| 60 | + setThemeState(initialTheme); |
| 61 | + const resolved = resolveTheme(initialTheme); |
| 62 | + setResolvedTheme(resolved); |
| 63 | + applyToDocument(resolved); |
| 64 | + }, [initialTheme]); |
| 65 | + |
| 66 | + React.useEffect(() => { |
| 67 | + const resolved = resolveTheme(theme); |
| 68 | + setResolvedTheme(resolved); |
| 69 | + applyToDocument(resolved); |
| 70 | + }, [theme]); |
| 71 | + |
| 72 | + React.useEffect(() => { |
| 73 | + if (theme !== "system") return; |
| 74 | + const mq = window.matchMedia("(prefers-color-scheme: dark)"); |
| 75 | + const onChange = () => { |
| 76 | + const resolved = resolveTheme("system"); |
| 77 | + setResolvedTheme(resolved); |
| 78 | + applyToDocument(resolved); |
| 79 | + }; |
| 80 | + mq.addEventListener("change", onChange); |
| 81 | + return () => mq.removeEventListener("change", onChange); |
| 82 | + }, [theme]); |
| 83 | + |
| 84 | + React.useEffect(() => { |
| 85 | + if (!migrateThemeFromLocalStorage) return; |
| 86 | + try { |
| 87 | + const v = localStorage.getItem("theme"); |
| 88 | + if (v !== "light" && v !== "dark" && v !== "system") return; |
| 89 | + void setThemePreference(v).then((r) => { |
| 90 | + if (r.ok) setThemeState(v); |
| 91 | + }); |
| 92 | + } catch { |
| 93 | + /* private mode */ |
| 94 | + } |
| 95 | + }, [migrateThemeFromLocalStorage]); |
| 96 | + |
| 97 | + const setTheme = React.useCallback((t: ThemePreference) => { |
| 98 | + setThemeState(t); |
| 99 | + void setThemePreference(t); |
| 100 | + }, []); |
| 101 | + |
| 102 | + const value = React.useMemo( |
| 103 | + () => ({ theme, setTheme, resolvedTheme }), |
| 104 | + [theme, setTheme, resolvedTheme], |
| 105 | + ); |
| 106 | + |
| 107 | + return ( |
| 108 | + <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> |
| 109 | + ); |
| 110 | +} |
0 commit comments