/*
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, setCookie, removeCookie } from '@/lib/cookies'
type Theme = 'dark' | 'light' | 'system'
type ResolvedTheme = Exclude
const DEFAULT_THEME = 'system'
const THEME_COOKIE_NAME = 'vite-ui-theme'
const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year
const THEMES = new Set(['dark', 'light', 'system'])
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
defaultTheme: Theme
resolvedTheme: ResolvedTheme
theme: Theme
setTheme: (theme: Theme) => void
resetTheme: () => void
}
const initialState: ThemeProviderState = {
defaultTheme: DEFAULT_THEME,
resolvedTheme: 'light',
theme: DEFAULT_THEME,
setTheme: () => null,
resetTheme: () => null,
}
const ThemeContext = createContext(initialState)
function getSystemTheme(): ResolvedTheme {
if (typeof window === 'undefined') return 'light'
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
function resolveTheme(theme: Theme): ResolvedTheme {
return theme === 'system' ? getSystemTheme() : theme
}
function getStoredTheme(storageKey: string, fallback: Theme): Theme {
const storedTheme = getCookie(storageKey) as Theme | undefined
return storedTheme && THEMES.has(storedTheme) ? storedTheme : fallback
}
export function ThemeProvider({
children,
defaultTheme = DEFAULT_THEME,
storageKey = THEME_COOKIE_NAME,
...props
}: ThemeProviderProps) {
const [theme, _setTheme] = useState(() =>
getStoredTheme(storageKey, defaultTheme)
)
const [resolvedTheme, setResolvedTheme] = useState(() =>
resolveTheme(getStoredTheme(storageKey, defaultTheme))
)
useEffect(() => {
const root = window.document.documentElement
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const applyTheme = () => {
const nextResolvedTheme = theme === 'system' ? getSystemTheme() : theme
root.classList.remove('light', 'dark')
root.classList.add(nextResolvedTheme)
setResolvedTheme(nextResolvedTheme)
}
applyTheme()
mediaQuery.addEventListener('change', applyTheme)
return () => mediaQuery.removeEventListener('change', applyTheme)
}, [theme])
const setTheme = useCallback(
(theme: Theme) => {
setCookie(storageKey, theme, THEME_COOKIE_MAX_AGE)
_setTheme(theme)
},
[storageKey]
)
const resetTheme = useCallback(() => {
removeCookie(storageKey)
_setTheme(defaultTheme)
}, [defaultTheme, storageKey])
const contextValue = useMemo(
() => ({
defaultTheme,
resolvedTheme,
resetTheme,
theme,
setTheme,
}),
[defaultTheme, resolvedTheme, resetTheme, theme, setTheme]
)
return (
{children}
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) throw new Error('useTheme must be used within a ThemeProvider')
return context
}