Files
bedolaga-cabinet/src/hooks/useTheme.ts
c0mrade bf00d37b4a fix: add theme toggle to desktop header and sync theme across components
Add sun/moon toggle button to the desktop navigation bar so theme
switching is available in web version. Sync theme state across multiple
useTheme() instances via custom DOM event to avoid requiring page reload.
2026-02-04 12:31:32 +03:00

249 lines
7.5 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme';
import { themeColorsApi } from '../api/themeColors';
type Theme = 'dark' | 'light';
const THEME_KEY = 'cabinet-theme';
const ENABLED_THEMES_KEY = 'cabinet-enabled-themes';
// Fetch enabled themes from API
async function fetchEnabledThemes(): Promise<EnabledThemes> {
try {
const data = await themeColorsApi.getEnabledThemes();
// Cache in localStorage for faster subsequent loads
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(data));
return data;
} catch {
// Ignore errors, use cached or default
}
// Try to get from cache
const cached = localStorage.getItem(ENABLED_THEMES_KEY);
if (cached) {
try {
return JSON.parse(cached);
} catch {
// Ignore parse errors
}
}
return DEFAULT_ENABLED_THEMES;
}
// Get cached enabled themes synchronously
function getCachedEnabledThemes(): EnabledThemes {
const cached = localStorage.getItem(ENABLED_THEMES_KEY);
if (cached) {
try {
return JSON.parse(cached);
} catch {
// Ignore parse errors
}
}
return DEFAULT_ENABLED_THEMES;
}
// Custom events for same-tab updates
const ENABLED_THEMES_CHANGED_EVENT = 'enabledThemesChanged';
const THEME_CHANGED_EVENT = 'themeChanged';
// Update cache (called from admin settings)
export function updateEnabledThemesCache(themes: EnabledThemes) {
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(themes));
// Dispatch custom event for same-tab updates
window.dispatchEvent(new CustomEvent(ENABLED_THEMES_CHANGED_EVENT, { detail: themes }));
}
export function useTheme() {
const [enabledThemes, setEnabledThemes] = useState<EnabledThemes>(getCachedEnabledThemes);
const [isLoading, setIsLoading] = useState(true);
const [theme, setThemeState] = useState<Theme>(() => {
const enabled = getCachedEnabledThemes();
// Check localStorage first
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(THEME_KEY) as Theme | null;
if (stored === 'light' && enabled.light) {
return 'light';
}
if (stored === 'dark' && enabled.dark) {
return 'dark';
}
// If stored theme is disabled, use the enabled one
if (stored && !enabled[stored]) {
return enabled.dark ? 'dark' : 'light';
}
// Check system preference
if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) {
return 'light';
}
}
// Default to dark if enabled, otherwise light
return enabled.dark ? 'dark' : 'light';
});
// Fetch enabled themes on mount
useEffect(() => {
fetchEnabledThemes().then((data) => {
setEnabledThemes(data);
setIsLoading(false);
// If current theme is disabled, switch to enabled one
if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme);
}
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Listen for localStorage changes (when admin updates enabled themes from other tabs)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === ENABLED_THEMES_KEY && e.newValue) {
try {
const data = JSON.parse(e.newValue) as EnabledThemes;
setEnabledThemes(data);
// If current theme is now disabled, switch to enabled one
if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme);
}
} catch {
// Ignore parse errors
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme]);
// Listen for same-tab enabled themes changes (from admin settings)
useEffect(() => {
const handleEnabledThemesChange = (e: CustomEvent<EnabledThemes>) => {
const data = e.detail;
setEnabledThemes(data);
// If current theme is now disabled, switch to enabled one
if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme);
}
};
window.addEventListener(
ENABLED_THEMES_CHANGED_EVENT,
handleEnabledThemesChange as EventListener,
);
return () =>
window.removeEventListener(
ENABLED_THEMES_CHANGED_EVENT,
handleEnabledThemesChange as EventListener,
);
}, [theme]);
// Apply theme to document - also check if theme is disabled and switch
useEffect(() => {
const root = document.documentElement;
// If current theme is disabled, switch to the enabled one
if (!enabledThemes[theme]) {
const newTheme = enabledThemes.dark ? 'dark' : 'light';
if (newTheme !== theme) {
setThemeState(newTheme);
return; // Will re-run with correct theme
}
}
if (theme === 'light') {
root.classList.remove('dark');
root.classList.add('light');
} else {
root.classList.remove('light');
root.classList.add('dark');
}
localStorage.setItem(THEME_KEY, theme);
// Notify other useTheme() instances in the same tab
window.dispatchEvent(new CustomEvent(THEME_CHANGED_EVENT, { detail: theme }));
}, [theme, enabledThemes]);
// Listen for same-tab theme changes (from other useTheme() instances)
useEffect(() => {
const handleThemeChange = (e: CustomEvent<Theme>) => {
setThemeState(e.detail);
};
window.addEventListener(THEME_CHANGED_EVENT, handleThemeChange as EventListener);
return () =>
window.removeEventListener(THEME_CHANGED_EVENT, handleThemeChange as EventListener);
}, []);
// Listen for system theme changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)');
const handleChange = (e: MediaQueryListEvent) => {
const stored = localStorage.getItem(THEME_KEY);
// Only auto-switch if user hasn't set a preference and theme is enabled
if (!stored) {
const newTheme = e.matches ? 'light' : 'dark';
if (enabledThemes[newTheme]) {
setThemeState(newTheme);
}
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [enabledThemes]);
const setTheme = useCallback(
(newTheme: Theme) => {
// Only allow setting if theme is enabled
if (enabledThemes[newTheme]) {
setThemeState(newTheme);
}
},
[enabledThemes],
);
const toggleTheme = useCallback(() => {
setThemeState((prev) => {
const newTheme = prev === 'dark' ? 'light' : 'dark';
// Only toggle if the new theme is enabled
if (enabledThemes[newTheme]) {
return newTheme;
}
return prev;
});
}, [enabledThemes]);
const isDark = theme === 'dark';
const isLight = theme === 'light';
// Check if theme switching is available (both themes enabled and loaded)
const canToggle = !isLoading && enabledThemes.dark && enabledThemes.light;
// Refresh enabled themes from API
const refreshEnabledThemes = useCallback(() => {
fetchEnabledThemes().then((data) => {
setEnabledThemes(data);
if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme);
}
});
}, [theme]);
return {
theme,
setTheme,
toggleTheme,
isDark,
isLight,
enabledThemes,
canToggle,
isLoading,
refreshEnabledThemes,
};
}