/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react' import { getCookie, removeCookie, setCookie } from '@/lib/cookies' import { CONTENT_LAYOUT_VALUES, type ContentLayout, DEFAULT_THEME_CUSTOMIZATION, resolveThemeFont, THEME_COOKIE_KEYS, THEME_FONT_VALUES, THEME_PRESET_VALUES, THEME_RADIUS_VALUES, THEME_SCALE_VALUES, type ThemeCustomization, type ThemeFont, type ThemePreset, type ThemeRadius, type ThemeScale, } from '@/lib/theme-customization' const COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year function readCookie( name: string, allowed: ReadonlySet, fallback: T ): T { const value = getCookie(name) return value && allowed.has(value as T) ? (value as T) : fallback } function applyAttribute(name: string, value: string | null) { if (typeof document === 'undefined') return const body = document.body if (!body) return if (value === null) { body.removeAttribute(name) } else { body.setAttribute(name, value) } } type ThemeCustomizationContextType = { defaults: ThemeCustomization customization: ThemeCustomization setPreset: (preset: ThemePreset) => void setFont: (font: ThemeFont) => void setRadius: (radius: ThemeRadius) => void setScale: (scale: ThemeScale) => void setContentLayout: (contentLayout: ContentLayout) => void resetCustomization: () => void } // Fallback used when a consumer renders outside the provider (e.g. an error // route mounted before providers are ready, or stale HMR boundaries). Keeping // it permissive prevents the whole tree from crashing — the UI just behaves // like the defaults until the real provider re-mounts. const FALLBACK_CONTEXT: ThemeCustomizationContextType = { defaults: DEFAULT_THEME_CUSTOMIZATION, customization: DEFAULT_THEME_CUSTOMIZATION, setPreset: () => {}, setFont: () => {}, setRadius: () => {}, setScale: () => {}, setContentLayout: () => {}, resetCustomization: () => {}, } const ThemeCustomizationContext = createContext(FALLBACK_CONTEXT) export function ThemeCustomizationProvider(props: { children: React.ReactNode }) { const [preset, _setPreset] = useState(() => readCookie( THEME_COOKIE_KEYS.preset, THEME_PRESET_VALUES, DEFAULT_THEME_CUSTOMIZATION.preset ) ) const [font, _setFont] = useState(() => readCookie( THEME_COOKIE_KEYS.font, THEME_FONT_VALUES, DEFAULT_THEME_CUSTOMIZATION.font ) ) const [radius, _setRadius] = useState(() => readCookie( THEME_COOKIE_KEYS.radius, THEME_RADIUS_VALUES, DEFAULT_THEME_CUSTOMIZATION.radius ) ) const [scale, _setScale] = useState(() => readCookie( THEME_COOKIE_KEYS.scale, THEME_SCALE_VALUES, DEFAULT_THEME_CUSTOMIZATION.scale ) ) const [contentLayout, _setContentLayout] = useState(() => readCookie( THEME_COOKIE_KEYS.contentLayout, CONTENT_LAYOUT_VALUES, DEFAULT_THEME_CUSTOMIZATION.contentLayout ) ) // Mirror state to the via data-* attributes so theme-presets.css can // override CSS variables at the right cascade layer. useEffect(() => { applyAttribute( 'data-theme-preset', preset === DEFAULT_THEME_CUSTOMIZATION.preset ? null : preset ) }, [preset]) // Font is the one axis where we resolve before writing the attribute: // the persisted preference may be `default`, but CSS works in terms of // the concrete `sans`/`serif` choice that should drive the cascade. // Resolving here (instead of in CSS via `:not()` selectors) keeps the // stylesheet to one simple `[data-theme-font='serif']` selector and lets // future presets opt into typography via `PRESET_DEFAULT_FONT` alone. useEffect(() => { applyAttribute('data-theme-font', resolveThemeFont(font, preset)) }, [font, preset]) useEffect(() => { applyAttribute( 'data-theme-radius', radius === DEFAULT_THEME_CUSTOMIZATION.radius ? null : radius ) }, [radius]) useEffect(() => { applyAttribute( 'data-theme-scale', scale === DEFAULT_THEME_CUSTOMIZATION.scale ? null : scale ) }, [scale]) useEffect(() => { applyAttribute('data-theme-content-layout', contentLayout) }, [contentLayout]) const setPreset = useCallback((value: ThemePreset) => { _setPreset(value) if (value === DEFAULT_THEME_CUSTOMIZATION.preset) { removeCookie(THEME_COOKIE_KEYS.preset) } else { setCookie(THEME_COOKIE_KEYS.preset, value, COOKIE_MAX_AGE) } }, []) const setFont = useCallback((value: ThemeFont) => { _setFont(value) if (value === DEFAULT_THEME_CUSTOMIZATION.font) { removeCookie(THEME_COOKIE_KEYS.font) } else { setCookie(THEME_COOKIE_KEYS.font, value, COOKIE_MAX_AGE) } }, []) const setRadius = useCallback((value: ThemeRadius) => { _setRadius(value) if (value === DEFAULT_THEME_CUSTOMIZATION.radius) { removeCookie(THEME_COOKIE_KEYS.radius) } else { setCookie(THEME_COOKIE_KEYS.radius, value, COOKIE_MAX_AGE) } }, []) const setScale = useCallback((value: ThemeScale) => { _setScale(value) if (value === DEFAULT_THEME_CUSTOMIZATION.scale) { removeCookie(THEME_COOKIE_KEYS.scale) } else { setCookie(THEME_COOKIE_KEYS.scale, value, COOKIE_MAX_AGE) } }, []) const setContentLayout = useCallback((value: ContentLayout) => { _setContentLayout(value) if (value === DEFAULT_THEME_CUSTOMIZATION.contentLayout) { removeCookie(THEME_COOKIE_KEYS.contentLayout) } else { setCookie(THEME_COOKIE_KEYS.contentLayout, value, COOKIE_MAX_AGE) } }, []) const resetCustomization = useCallback(() => { setPreset(DEFAULT_THEME_CUSTOMIZATION.preset) setFont(DEFAULT_THEME_CUSTOMIZATION.font) setRadius(DEFAULT_THEME_CUSTOMIZATION.radius) setScale(DEFAULT_THEME_CUSTOMIZATION.scale) setContentLayout(DEFAULT_THEME_CUSTOMIZATION.contentLayout) }, [setPreset, setFont, setRadius, setScale, setContentLayout]) const value = useMemo( () => ({ defaults: DEFAULT_THEME_CUSTOMIZATION, customization: { preset, font, radius, scale, contentLayout }, setPreset, setFont, setRadius, setScale, setContentLayout, resetCustomization, }), [ preset, font, radius, scale, contentLayout, setPreset, setFont, setRadius, setScale, setContentLayout, resetCustomization, ] ) return ( {props.children} ) } // eslint-disable-next-line react-refresh/only-export-components export function useThemeCustomization() { return useContext(ThemeCustomizationContext) }