JavaScript 56.7%
TypeScript 42.6%
1"use client";23import { createContext, useContext, useEffect, useMemo, useState, useCallback } from "react";4import { MotionConfig } from "framer-motion";5import {6 ACCENTS,7 DEFAULT_ACCENT,8 DEFAULT_THEME,9 PREF_KEYS,10 isAccentId,11 type AccentId,12 type DensityPref,13 type ThemePref,14} from "@/lib/prefs";1516type Theme = "dark" | "light";1718interface ThemeContextType {19 /** Thème effectif (résolu) */20 theme: Theme;21 /** Préférence brute (light | dark | system) */22 themePref: ThemePref;23 setThemePref: (t: ThemePref) => void;24 toggleTheme: () => void;25 accent: AccentId;26 setAccent: (a: AccentId) => void;27 density: DensityPref;28 setDensity: (d: DensityPref) => void;29 reduceMotion: boolean;30 setReduceMotion: (v: boolean) => void;31 mounted: boolean;32}3334const ThemeContext = createContext<ThemeContextType>({35 theme: "light",36 themePref: "light",37 setThemePref: () => {},38 toggleTheme: () => {},39 accent: DEFAULT_ACCENT,40 setAccent: () => {},41 density: "comfortable",42 setDensity: () => {},43 reduceMotion: false,44 setReduceMotion: () => {},45 mounted: false,46});4748export function useTheme() {49 return useContext(ThemeContext);50}5152function systemPrefersDark(): boolean {53 return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches;54}5556function resolveTheme(pref: ThemePref): Theme {57 if (pref === "system") return systemPrefersDark() ? "dark" : "light";58 return pref;59}6061export default function ThemeProvider({ children }: { children: React.ReactNode }) {62 const [themePref, setThemePrefState] = useState<ThemePref>(DEFAULT_THEME);63 const [theme, setTheme] = useState<Theme>("light");64 const [accent, setAccentState] = useState<AccentId>(DEFAULT_ACCENT);65 const [density, setDensityState] = useState<DensityPref>("comfortable");66 const [reduceMotion, setReduceMotionState] = useState(false);67 const [mounted, setMounted] = useState(false);6869 // Lecture initiale (le script inline de layout.tsx a déjà appliqué les classes)70 useEffect(() => {71 try {72 const t = localStorage.getItem(PREF_KEYS.theme);73 const pref: ThemePref = t === "dark" || t === "light" || t === "system" ? t : DEFAULT_THEME;74 setThemePrefState(pref);75 setTheme(resolveTheme(pref));76 const a = localStorage.getItem(PREF_KEYS.accent);77 if (isAccentId(a)) setAccentState(a);78 const d = localStorage.getItem(PREF_KEYS.density);79 if (d === "compact" || d === "comfortable") setDensityState(d);80 const m = localStorage.getItem(PREF_KEYS.motion);81 setReduceMotionState(m === "reduced");82 } catch {}83 setMounted(true);84 }, []);8586 // Suivre le système si "system"87 useEffect(() => {88 if (themePref !== "system") return;89 const mq = window.matchMedia("(prefers-color-scheme: dark)");90 const onChange = () => setTheme(mq.matches ? "dark" : "light");91 mq.addEventListener("change", onChange);92 return () => mq.removeEventListener("change", onChange);93 }, [themePref]);9495 // Appliquer au DOM96 useEffect(() => {97 if (!mounted) return;98 const root = document.documentElement;99 root.classList.toggle("dark", theme === "dark");100 root.classList.toggle("light", theme !== "dark");101 root.style.colorScheme = theme;102 const meta = document.querySelector('meta[name="theme-color"]');103 if (meta) meta.setAttribute("content", theme === "dark" ? "#0a0b10" : "#f5f6f8");104 }, [theme, mounted]);105106 useEffect(() => {107 if (!mounted) return;108 document.documentElement.setAttribute("data-accent", accent);109 }, [accent, mounted]);110111 useEffect(() => {112 if (!mounted) return;113 document.documentElement.setAttribute("data-density", density);114 }, [density, mounted]);115116 useEffect(() => {117 if (!mounted) return;118 document.documentElement.setAttribute("data-motion", reduceMotion ? "reduced" : "normal");119 }, [reduceMotion, mounted]);120121 const setThemePref = useCallback((pref: ThemePref) => {122 setThemePrefState(pref);123 setTheme(resolveTheme(pref));124 try { localStorage.setItem(PREF_KEYS.theme, pref); } catch {}125 }, []);126127 const toggleTheme = useCallback(() => {128 setThemePref(theme === "dark" ? "light" : "dark");129 }, [theme, setThemePref]);130131 const setAccent = useCallback((a: AccentId) => {132 if (!ACCENTS.some((x) => x.id === a)) return;133 setAccentState(a);134 try { localStorage.setItem(PREF_KEYS.accent, a); } catch {}135 }, []);136137 const setDensity = useCallback((d: DensityPref) => {138 setDensityState(d);139 try { localStorage.setItem(PREF_KEYS.density, d); } catch {}140 }, []);141142 const setReduceMotion = useCallback((v: boolean) => {143 setReduceMotionState(v);144 try { localStorage.setItem(PREF_KEYS.motion, v ? "reduced" : "normal"); } catch {}145 }, []);146147 const value = useMemo<ThemeContextType>(148 () => ({149 theme, themePref, setThemePref, toggleTheme,150 accent, setAccent, density, setDensity,151 reduceMotion, setReduceMotion, mounted,152 }),153 [theme, themePref, setThemePref, toggleTheme, accent, setAccent, density, setDensity, reduceMotion, setReduceMotion, mounted]154 );155156 return (157 <ThemeContext.Provider value={value}>158 <MotionConfig reducedMotion={reduceMotion ? "always" : "user"}>{children}</MotionConfig>159 </ThemeContext.Provider>160 );161}162