mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-13 13:04:24 +01:00
💡 fix: System Theme Picker Selection (#11220)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* fix: theme picker selection * refactor: remove problematic Jotai use and replace with React state and localStorage implementation * chore: address comments from Copilot + LibreChat Agent assisted reviewers * chore: remove unnecessary edit * chore: remove space
This commit is contained in:
parent
5b67e48fe1
commit
cc7f61096b
3 changed files with 160 additions and 75 deletions
|
|
@ -77,13 +77,6 @@ const ThemeSelector = ({ returnThemeOnly }: { returnThemeOnly?: boolean }) => {
|
|||
[setTheme, localize],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'system') {
|
||||
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
setTheme(prefersDarkScheme ? 'dark' : 'light');
|
||||
}
|
||||
}, [theme, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (announcement) {
|
||||
const timeout = setTimeout(() => setAnnouncement(''), 1000);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
// This file is kept for backward compatibility but is no longer used internally.
|
||||
// Theme state is now managed via React useState + localStorage in ThemeProvider.
|
||||
|
||||
import { atomWithStorage } from 'jotai/utils';
|
||||
import { IThemeRGB } from '../types';
|
||||
|
||||
/**
|
||||
* Atom for storing the theme mode (light/dark/system) in localStorage
|
||||
* Key: 'color-theme'
|
||||
* @deprecated Use ThemeContext instead. This atom is no longer used internally.
|
||||
*/
|
||||
export const themeModeAtom = atomWithStorage<string>('color-theme', 'system', undefined, {
|
||||
getOnInit: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Atom for storing custom theme colors in localStorage
|
||||
* Key: 'theme-colors'
|
||||
* @deprecated Use ThemeContext instead. This atom is no longer used internally.
|
||||
*/
|
||||
export const themeColorsAtom = atomWithStorage<IThemeRGB | undefined>(
|
||||
'theme-colors',
|
||||
|
|
@ -23,8 +24,7 @@ export const themeColorsAtom = atomWithStorage<IThemeRGB | undefined>(
|
|||
);
|
||||
|
||||
/**
|
||||
* Atom for storing the theme name in localStorage
|
||||
* Key: 'theme-name'
|
||||
* @deprecated Use ThemeContext instead. This atom is no longer used internally.
|
||||
*/
|
||||
export const themeNameAtom = atomWithStorage<string | undefined>(
|
||||
'theme-name',
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import React, { createContext, useContext, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import React, { createContext, useContext, useEffect, useMemo, useCallback, useState, useRef } from 'react';
|
||||
import { IThemeRGB } from '../types';
|
||||
import applyTheme from '../utils/applyTheme';
|
||||
import { themeModeAtom, themeColorsAtom, themeNameAtom } from '../atoms/themeAtoms';
|
||||
|
||||
const THEME_KEY = 'color-theme';
|
||||
const THEME_COLORS_KEY = 'theme-colors';
|
||||
const THEME_NAME_KEY = 'theme-name';
|
||||
|
||||
type ThemeContextType = {
|
||||
theme: string; // 'light' | 'dark' | 'system'
|
||||
|
|
@ -40,6 +42,70 @@ export const isDark = (theme: string): boolean => {
|
|||
return theme === 'dark';
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate that a parsed value looks like an IThemeRGB object
|
||||
*/
|
||||
const isValidThemeColors = (value: unknown): value is IThemeRGB => {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
const val = (value as Record<string, unknown>)[key];
|
||||
if (val !== undefined && typeof val !== 'string') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get initial theme from localStorage or default to 'system'
|
||||
*/
|
||||
const getInitialTheme = (): string => {
|
||||
if (typeof window === 'undefined') return 'system';
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_KEY);
|
||||
if (stored && ['light', 'dark', 'system'].includes(stored)) {
|
||||
return stored;
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
return 'system';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get initial theme colors from localStorage
|
||||
*/
|
||||
const getInitialThemeColors = (): IThemeRGB | undefined => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_COLORS_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (isValidThemeColors(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available or invalid JSON
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get initial theme name from localStorage
|
||||
*/
|
||||
const getInitialThemeName = (): string | undefined => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
try {
|
||||
return localStorage.getItem(THEME_NAME_KEY) || undefined;
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* ThemeProvider component that handles both dark/light mode switching
|
||||
* and dynamic color themes via CSS variables with localStorage persistence
|
||||
|
|
@ -50,102 +116,128 @@ export function ThemeProvider({
|
|||
themeName: propThemeName,
|
||||
initialTheme,
|
||||
}: ThemeProviderProps) {
|
||||
// Use jotai atoms for persistent state
|
||||
const [theme, setTheme] = useAtom(themeModeAtom);
|
||||
const [storedThemeRGB, setStoredThemeRGB] = useAtom(themeColorsAtom);
|
||||
const [storedThemeName, setStoredThemeName] = useAtom(themeNameAtom);
|
||||
const [theme, setThemeState] = useState<string>(getInitialTheme);
|
||||
const [themeRGB, setThemeRGBState] = useState<IThemeRGB | undefined>(getInitialThemeColors);
|
||||
const [themeName, setThemeNameState] = useState<string | undefined>(getInitialThemeName);
|
||||
|
||||
// Track if props have been initialized
|
||||
const propsInitialized = useRef(false);
|
||||
const initialized = useRef(false);
|
||||
|
||||
const setTheme = useCallback((newTheme: string) => {
|
||||
setThemeState(newTheme);
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, newTheme);
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setThemeRGB = useCallback((colors?: IThemeRGB) => {
|
||||
setThemeRGBState(colors);
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
if (colors) {
|
||||
localStorage.setItem(THEME_COLORS_KEY, JSON.stringify(colors));
|
||||
} else {
|
||||
localStorage.removeItem(THEME_COLORS_KEY);
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setThemeName = useCallback((name?: string) => {
|
||||
setThemeNameState(name);
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
if (name) {
|
||||
localStorage.setItem(THEME_NAME_KEY, name);
|
||||
} else {
|
||||
localStorage.removeItem(THEME_NAME_KEY);
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize from props only once on mount
|
||||
useEffect(() => {
|
||||
if (!propsInitialized.current) {
|
||||
propsInitialized.current = true;
|
||||
if (initialized.current) return;
|
||||
initialized.current = true;
|
||||
|
||||
// Set initial theme if provided
|
||||
if (initialTheme) {
|
||||
setTheme(initialTheme);
|
||||
}
|
||||
|
||||
// Set initial theme colors if provided
|
||||
if (propThemeRGB) {
|
||||
setStoredThemeRGB(propThemeRGB);
|
||||
}
|
||||
|
||||
// Set initial theme name if provided
|
||||
if (propThemeName) {
|
||||
setStoredThemeName(propThemeName);
|
||||
}
|
||||
// Set initial theme if provided
|
||||
if (initialTheme) {
|
||||
setTheme(initialTheme);
|
||||
}
|
||||
}, [initialTheme, propThemeRGB, propThemeName, setTheme, setStoredThemeRGB, setStoredThemeName]);
|
||||
|
||||
// Set initial theme colors if provided
|
||||
if (propThemeRGB) {
|
||||
setThemeRGB(propThemeRGB);
|
||||
}
|
||||
|
||||
// Set initial theme name if provided
|
||||
if (propThemeName) {
|
||||
setThemeName(propThemeName);
|
||||
}
|
||||
}, [initialTheme, propThemeRGB, propThemeName, setTheme, setThemeRGB, setThemeName]);
|
||||
|
||||
// Apply class-based dark mode
|
||||
const applyThemeMode = useCallback((rawTheme: string) => {
|
||||
const applyThemeMode = useCallback((currentTheme: string) => {
|
||||
const root = window.document.documentElement;
|
||||
const darkMode = isDark(rawTheme);
|
||||
const darkMode = isDark(currentTheme);
|
||||
|
||||
root.classList.remove(darkMode ? 'light' : 'dark');
|
||||
root.classList.add(darkMode ? 'dark' : 'light');
|
||||
}, []);
|
||||
|
||||
// Handle system theme changes
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const changeThemeOnSystemChange = () => {
|
||||
if (theme === 'system') {
|
||||
applyThemeMode('system');
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', changeThemeOnSystemChange);
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', changeThemeOnSystemChange);
|
||||
};
|
||||
}, [theme, applyThemeMode]);
|
||||
|
||||
// Apply dark/light mode class
|
||||
// Apply theme mode whenever theme changes
|
||||
useEffect(() => {
|
||||
applyThemeMode(theme);
|
||||
}, [theme, applyThemeMode]);
|
||||
|
||||
// Listen for system theme changes when theme is 'system'
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = () => {
|
||||
applyThemeMode('system');
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme, applyThemeMode]);
|
||||
|
||||
// Apply dynamic color theme
|
||||
useEffect(() => {
|
||||
if (storedThemeRGB) {
|
||||
applyTheme(storedThemeRGB);
|
||||
if (themeRGB) {
|
||||
applyTheme(themeRGB);
|
||||
}
|
||||
}, [storedThemeRGB]);
|
||||
}, [themeRGB]);
|
||||
|
||||
// Reset theme function
|
||||
const resetTheme = useCallback(() => {
|
||||
setTheme('system');
|
||||
setStoredThemeRGB(undefined);
|
||||
setStoredThemeName(undefined);
|
||||
setThemeRGB(undefined);
|
||||
setThemeName(undefined);
|
||||
// Remove any custom CSS variables
|
||||
const root = document.documentElement;
|
||||
const customProps = Array.from(root.style).filter((prop) => prop.startsWith('--'));
|
||||
customProps.forEach((prop) => root.style.removeProperty(prop));
|
||||
}, [setTheme, setStoredThemeRGB, setStoredThemeName]);
|
||||
}, [setTheme, setThemeRGB, setThemeName]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
setTheme,
|
||||
themeRGB: storedThemeRGB,
|
||||
setThemeRGB: setStoredThemeRGB,
|
||||
themeName: storedThemeName,
|
||||
setThemeName: setStoredThemeName,
|
||||
themeRGB,
|
||||
setThemeRGB,
|
||||
themeName,
|
||||
setThemeName,
|
||||
resetTheme,
|
||||
}),
|
||||
[
|
||||
theme,
|
||||
setTheme,
|
||||
storedThemeRGB,
|
||||
setStoredThemeRGB,
|
||||
storedThemeName,
|
||||
setStoredThemeName,
|
||||
resetTheme,
|
||||
],
|
||||
[theme, setTheme, themeRGB, setThemeRGB, themeName, setThemeName, resetTheme],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue