import { ReactNode, useLayoutEffect, useEffect, useRef } from 'react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { AppContext, type AppConfig, type AppContextType, type Theme } from '@/contexts/AppContext'; import { builtinThemes, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes'; import { AppConfigSchema } from '@/lib/schemas'; import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader'; import { hslToRgb, parseHsl, rgbToHex } from '@/lib/colorUtils'; import { z } from 'zod'; interface AppProviderProps { children: ReactNode; /** Application storage key */ storageKey: string; /** Default app configuration */ defaultConfig: AppConfig; } export function AppProvider(props: AppProviderProps) { const { children, storageKey, defaultConfig, } = props; // App configuration state with localStorage persistence. // The deserializer uses safeParse per top-level field so that a single // invalid/incomplete field (e.g. feedSettings missing a new key) doesn't // nuke the entire config back to defaults. Valid fields are preserved. const [rawConfig, setConfig] = useLocalStorage>( storageKey, {}, { serialize: JSON.stringify, deserialize: (value: string) => { const parsed = JSON.parse(value); if (typeof parsed !== 'object' || parsed === null) return {}; const result: Partial = {}; // Validate each top-level field individually for (const key of Object.keys(parsed)) { const fieldSchema = AppConfigSchema.shape[key as keyof typeof AppConfigSchema.shape]; if (fieldSchema) { const fieldResult = fieldSchema.safeParse(parsed[key]); if (fieldResult.success) { (result as Record)[key] = fieldResult.data; } } } // Migrate legacy blossomServers (string[]) to blossomServerMetadata if (!result.blossomServerMetadata) { const legacyServers = parsed.blossomServers; if (Array.isArray(legacyServers)) { const parsed2 = z.array(z.string().url()).safeParse(legacyServers); if (parsed2.success && parsed2.data.length > 0) { result.blossomServerMetadata = { servers: parsed2.data, updatedAt: 0, }; } } } return result; } } ); // Generic config updater with callback pattern const updateConfig = (updater: (currentConfig: Partial) => Partial) => { setConfig(updater); }; const config = { ...defaultConfig, ...rawConfig, // Deep-merge feedSettings so new keys added to the default are visible // even for existing users who have an older feedSettings in localStorage. feedSettings: { ...defaultConfig.feedSettings, ...rawConfig.feedSettings }, }; const appContextValue: AppContextType = { config, updateConfig, }; // Apply theme effects to document useApplyTheme(config.theme, config.customTheme, config.themes); useApplyFonts(config.theme, config.customTheme, config.themes); useApplyBackground(config.theme, config.customTheme, config.themes); useApplyFavicon(config.theme, config.customTheme, config.themes); return ( {children} ); } /** * Hook to apply theme changes to the document root via an injected