mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-21 01:44:09 +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],
|
[setTheme, localize],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (theme === 'system') {
|
|
||||||
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
setTheme(prefersDarkScheme ? 'dark' : 'light');
|
|
||||||
}
|
|
||||||
}, [theme, setTheme]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (announcement) {
|
if (announcement) {
|
||||||
const timeout = setTimeout(() => setAnnouncement(''), 1000);
|
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 { atomWithStorage } from 'jotai/utils';
|
||||||
import { IThemeRGB } from '../types';
|
import { IThemeRGB } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atom for storing the theme mode (light/dark/system) in localStorage
|
* @deprecated Use ThemeContext instead. This atom is no longer used internally.
|
||||||
* Key: 'color-theme'
|
|
||||||
*/
|
*/
|
||||||
export const themeModeAtom = atomWithStorage<string>('color-theme', 'system', undefined, {
|
export const themeModeAtom = atomWithStorage<string>('color-theme', 'system', undefined, {
|
||||||
getOnInit: true,
|
getOnInit: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atom for storing custom theme colors in localStorage
|
* @deprecated Use ThemeContext instead. This atom is no longer used internally.
|
||||||
* Key: 'theme-colors'
|
|
||||||
*/
|
*/
|
||||||
export const themeColorsAtom = atomWithStorage<IThemeRGB | undefined>(
|
export const themeColorsAtom = atomWithStorage<IThemeRGB | undefined>(
|
||||||
'theme-colors',
|
'theme-colors',
|
||||||
|
|
@ -23,8 +24,7 @@ export const themeColorsAtom = atomWithStorage<IThemeRGB | undefined>(
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atom for storing the theme name in localStorage
|
* @deprecated Use ThemeContext instead. This atom is no longer used internally.
|
||||||
* Key: 'theme-name'
|
|
||||||
*/
|
*/
|
||||||
export const themeNameAtom = atomWithStorage<string | undefined>(
|
export const themeNameAtom = atomWithStorage<string | undefined>(
|
||||||
'theme-name',
|
'theme-name',
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import React, { createContext, useContext, useEffect, useMemo, useCallback, useRef } from 'react';
|
import React, { createContext, useContext, useEffect, useMemo, useCallback, useState, useRef } from 'react';
|
||||||
import { useAtom } from 'jotai';
|
|
||||||
import { IThemeRGB } from '../types';
|
import { IThemeRGB } from '../types';
|
||||||
import applyTheme from '../utils/applyTheme';
|
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 = {
|
type ThemeContextType = {
|
||||||
theme: string; // 'light' | 'dark' | 'system'
|
theme: string; // 'light' | 'dark' | 'system'
|
||||||
|
|
@ -40,6 +42,70 @@ export const isDark = (theme: string): boolean => {
|
||||||
return theme === 'dark';
|
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
|
* ThemeProvider component that handles both dark/light mode switching
|
||||||
* and dynamic color themes via CSS variables with localStorage persistence
|
* and dynamic color themes via CSS variables with localStorage persistence
|
||||||
|
|
@ -50,102 +116,128 @@ export function ThemeProvider({
|
||||||
themeName: propThemeName,
|
themeName: propThemeName,
|
||||||
initialTheme,
|
initialTheme,
|
||||||
}: ThemeProviderProps) {
|
}: ThemeProviderProps) {
|
||||||
// Use jotai atoms for persistent state
|
const [theme, setThemeState] = useState<string>(getInitialTheme);
|
||||||
const [theme, setTheme] = useAtom(themeModeAtom);
|
const [themeRGB, setThemeRGBState] = useState<IThemeRGB | undefined>(getInitialThemeColors);
|
||||||
const [storedThemeRGB, setStoredThemeRGB] = useAtom(themeColorsAtom);
|
const [themeName, setThemeNameState] = useState<string | undefined>(getInitialThemeName);
|
||||||
const [storedThemeName, setStoredThemeName] = useAtom(themeNameAtom);
|
|
||||||
|
|
||||||
// Track if props have been initialized
|
// 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
|
// Initialize from props only once on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!propsInitialized.current) {
|
if (initialized.current) return;
|
||||||
propsInitialized.current = true;
|
initialized.current = true;
|
||||||
|
|
||||||
// Set initial theme if provided
|
// Set initial theme if provided
|
||||||
if (initialTheme) {
|
if (initialTheme) {
|
||||||
setTheme(initialTheme);
|
setTheme(initialTheme);
|
||||||
}
|
|
||||||
|
|
||||||
// Set initial theme colors if provided
|
|
||||||
if (propThemeRGB) {
|
|
||||||
setStoredThemeRGB(propThemeRGB);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set initial theme name if provided
|
|
||||||
if (propThemeName) {
|
|
||||||
setStoredThemeName(propThemeName);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [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
|
// Apply class-based dark mode
|
||||||
const applyThemeMode = useCallback((rawTheme: string) => {
|
const applyThemeMode = useCallback((currentTheme: string) => {
|
||||||
const root = window.document.documentElement;
|
const root = window.document.documentElement;
|
||||||
const darkMode = isDark(rawTheme);
|
const darkMode = isDark(currentTheme);
|
||||||
|
|
||||||
root.classList.remove(darkMode ? 'light' : 'dark');
|
root.classList.remove(darkMode ? 'light' : 'dark');
|
||||||
root.classList.add(darkMode ? 'dark' : 'light');
|
root.classList.add(darkMode ? 'dark' : 'light');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle system theme changes
|
// Apply theme mode whenever 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
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyThemeMode(theme);
|
applyThemeMode(theme);
|
||||||
}, [theme, applyThemeMode]);
|
}, [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
|
// Apply dynamic color theme
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (storedThemeRGB) {
|
if (themeRGB) {
|
||||||
applyTheme(storedThemeRGB);
|
applyTheme(themeRGB);
|
||||||
}
|
}
|
||||||
}, [storedThemeRGB]);
|
}, [themeRGB]);
|
||||||
|
|
||||||
// Reset theme function
|
// Reset theme function
|
||||||
const resetTheme = useCallback(() => {
|
const resetTheme = useCallback(() => {
|
||||||
setTheme('system');
|
setTheme('system');
|
||||||
setStoredThemeRGB(undefined);
|
setThemeRGB(undefined);
|
||||||
setStoredThemeName(undefined);
|
setThemeName(undefined);
|
||||||
// Remove any custom CSS variables
|
// Remove any custom CSS variables
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const customProps = Array.from(root.style).filter((prop) => prop.startsWith('--'));
|
const customProps = Array.from(root.style).filter((prop) => prop.startsWith('--'));
|
||||||
customProps.forEach((prop) => root.style.removeProperty(prop));
|
customProps.forEach((prop) => root.style.removeProperty(prop));
|
||||||
}, [setTheme, setStoredThemeRGB, setStoredThemeName]);
|
}, [setTheme, setThemeRGB, setThemeName]);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
theme,
|
theme,
|
||||||
setTheme,
|
setTheme,
|
||||||
themeRGB: storedThemeRGB,
|
themeRGB,
|
||||||
setThemeRGB: setStoredThemeRGB,
|
setThemeRGB,
|
||||||
themeName: storedThemeName,
|
themeName,
|
||||||
setThemeName: setStoredThemeName,
|
setThemeName,
|
||||||
resetTheme,
|
resetTheme,
|
||||||
}),
|
}),
|
||||||
[
|
[theme, setTheme, themeRGB, setThemeRGB, themeName, setThemeName, resetTheme],
|
||||||
theme,
|
|
||||||
setTheme,
|
|
||||||
storedThemeRGB,
|
|
||||||
setStoredThemeRGB,
|
|
||||||
storedThemeName,
|
|
||||||
setStoredThemeName,
|
|
||||||
resetTheme,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue