import { useEffect, useState, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { themeColorsApi } from '../../api/themeColors'; import { DEFAULT_THEME_COLORS, ThemeColors } from '../../types/theme'; import { ColorPicker } from '../ColorPicker'; import { applyThemeColors } from '../../hooks/useThemeColors'; import { updateEnabledThemesCache } from '../../hooks/useTheme'; import { MoonIcon, SunIcon, ChevronDownIcon } from './icons'; import { Toggle } from './Toggle'; import { THEME_PRESETS } from './constants'; function colorsEqual(a: ThemeColors, b: ThemeColors): boolean { return ( a.accent === b.accent && a.darkBackground === b.darkBackground && a.darkSurface === b.darkSurface && a.darkText === b.darkText && a.darkTextSecondary === b.darkTextSecondary && a.lightBackground === b.lightBackground && a.lightSurface === b.lightSurface && a.lightText === b.lightText && a.lightTextSecondary === b.lightTextSecondary && a.success === b.success && a.warning === b.warning && a.error === b.error ); } export function ThemeTab() { const { t } = useTranslation(); const queryClient = useQueryClient(); const [expandedSections, setExpandedSections] = useState>(new Set(['presets'])); const toggleSection = (section: string) => { setExpandedSections((prev) => { const next = new Set(prev); if (next.has(section)) { next.delete(section); } else { next.add(section); } return next; }); }; // Queries const { data: serverColors } = useQuery({ queryKey: ['theme-colors'], queryFn: themeColorsApi.getColors, }); const { data: enabledThemes } = useQuery({ queryKey: ['enabled-themes'], queryFn: themeColorsApi.getEnabledThemes, }); // Local draft state const [draftColors, setDraftColors] = useState(DEFAULT_THEME_COLORS); const savedColorsRef = useRef(DEFAULT_THEME_COLORS); const draftColorsRef = useRef(draftColors); draftColorsRef.current = draftColors; // Sync server data into draft and saved snapshot when it arrives useEffect(() => { if (serverColors) { const colors: ThemeColors = { accent: serverColors.accent, darkBackground: serverColors.darkBackground, darkSurface: serverColors.darkSurface, darkText: serverColors.darkText, darkTextSecondary: serverColors.darkTextSecondary, lightBackground: serverColors.lightBackground, lightSurface: serverColors.lightSurface, lightText: serverColors.lightText, lightTextSecondary: serverColors.lightTextSecondary, success: serverColors.success, warning: serverColors.warning, error: serverColors.error, }; // Only sync if saved snapshot matches current draft (no unsaved changes) if ( colorsEqual(savedColorsRef.current, draftColorsRef.current) || colorsEqual(savedColorsRef.current, DEFAULT_THEME_COLORS) ) { setDraftColors(colors); savedColorsRef.current = colors; } } }, [serverColors]); const hasUnsavedChanges = !colorsEqual(draftColors, savedColorsRef.current); // Mutations const updateColorsMutation = useMutation({ mutationFn: themeColorsApi.updateColors, onSuccess: (data) => { const colors: ThemeColors = { accent: data.accent, darkBackground: data.darkBackground, darkSurface: data.darkSurface, darkText: data.darkText, darkTextSecondary: data.darkTextSecondary, lightBackground: data.lightBackground, lightSurface: data.lightSurface, lightText: data.lightText, lightTextSecondary: data.lightTextSecondary, success: data.success, warning: data.warning, error: data.error, }; savedColorsRef.current = colors; setDraftColors(colors); applyThemeColors(colors); queryClient.setQueryData(['theme-colors'], data); }, }); const resetColorsMutation = useMutation({ mutationFn: themeColorsApi.resetColors, onSuccess: (data) => { const colors: ThemeColors = { accent: data.accent, darkBackground: data.darkBackground, darkSurface: data.darkSurface, darkText: data.darkText, darkTextSecondary: data.darkTextSecondary, lightBackground: data.lightBackground, lightSurface: data.lightSurface, lightText: data.lightText, lightTextSecondary: data.lightTextSecondary, success: data.success, warning: data.warning, error: data.error, }; savedColorsRef.current = colors; setDraftColors(colors); applyThemeColors(colors); queryClient.setQueryData(['theme-colors'], data); }, }); const updateEnabledThemesMutation = useMutation({ mutationFn: themeColorsApi.updateEnabledThemes, onSuccess: (data) => { updateEnabledThemesCache(data); queryClient.invalidateQueries({ queryKey: ['enabled-themes'] }); }, }); // Throttle applyThemeColors to once per animation frame const rafRef = useRef(0); const querySyncRef = useRef(0); const updateDraftColor = useCallback( (key: keyof ThemeColors, value: string) => { setDraftColors((prev) => { const next = { ...prev, [key]: value }; // Throttle CSS variable updates to 1x per frame cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(() => { applyThemeColors(next); }); // Debounce query cache update (triggers re-renders of other components) clearTimeout(querySyncRef.current); querySyncRef.current = window.setTimeout(() => { queryClient.setQueryData(['theme-colors'], next); }, 150); return next; }); }, [queryClient], ); // Cleanup on unmount useEffect(() => { return () => { cancelAnimationFrame(rafRef.current); clearTimeout(querySyncRef.current); }; }, []); // Apply a full preset and auto-save to server const applyPreset = useCallback( (colors: Partial) => { setDraftColors((prev) => { const next = { ...prev, ...colors }; // Preset is a one-shot action — apply immediately (no throttle needed) applyThemeColors(next); queryClient.setQueryData(['theme-colors'], next); // Auto-save preset to server so it persists across navigation updateColorsMutation.mutate(next); return next; }); }, [queryClient, updateColorsMutation], ); // Cancel: revert draft to saved const handleCancel = useCallback(() => { const saved = savedColorsRef.current; setDraftColors(saved); applyThemeColors(saved); queryClient.setQueryData(['theme-colors'], saved); }, [queryClient]); return (
{/* Theme toggles */}

{t('admin.settings.availableThemes')}

{t('admin.settings.darkTheme')}
{ if ((enabledThemes?.dark ?? true) && !(enabledThemes?.light ?? true)) return; updateEnabledThemesMutation.mutate({ dark: !(enabledThemes?.dark ?? true) }); }} disabled={updateEnabledThemesMutation.isPending} />
{t('admin.settings.lightTheme')}
{ if ((enabledThemes?.light ?? true) && !(enabledThemes?.dark ?? true)) return; updateEnabledThemesMutation.mutate({ light: !(enabledThemes?.light ?? true) }); }} disabled={updateEnabledThemesMutation.isPending} />
{/* Quick Presets */}
{expandedSections.has('presets') && (
{THEME_PRESETS.map((preset) => ( ))}
)}
{/* Custom Colors */}
{expandedSections.has('colors') && (
{/* Accent */}

{t('admin.settings.accentColor')}

updateDraftColor('accent', color)} />
{/* Dark theme */}

{t('admin.settings.darkTheme')}

updateDraftColor('darkBackground', color)} /> updateDraftColor('darkSurface', color)} /> updateDraftColor('darkText', color)} /> updateDraftColor('darkTextSecondary', color)} />
{/* Light theme */}

{t('admin.settings.lightTheme')}

updateDraftColor('lightBackground', color)} /> updateDraftColor('lightSurface', color)} /> updateDraftColor('lightText', color)} /> updateDraftColor('lightTextSecondary', color)} />
{/* Status colors */}

{t('admin.settings.statusColors')}

updateDraftColor('success', color)} /> updateDraftColor('warning', color)} /> updateDraftColor('error', color)} />
)}
{/* Save / Cancel — always visible when there are unsaved changes */} {hasUnsavedChanges && (
)} {/* Reset all colors */}
); }