AppProvider.tsx
1 import { ReactNode, useEffect } from 'react'; 2 import { z } from 'zod'; 3 import { useLocalStorage } from '@/hooks/useLocalStorage'; 4 import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext'; 5 6 interface AppProviderProps { 7 children: ReactNode; 8 /** Application storage key */ 9 storageKey: string; 10 /** Default app configuration */ 11 defaultConfig: AppConfig; 12 } 13 14 // Zod schema for RelayMetadata validation 15 const RelayMetadataSchema = z.object({ 16 relays: z.array(z.object({ 17 url: z.url(), 18 read: z.boolean(), 19 write: z.boolean(), 20 })), 21 updatedAt: z.number(), 22 }) satisfies z.ZodType<RelayMetadata>; 23 24 // Zod schema for AppConfig validation 25 const AppConfigSchema = z.object({ 26 theme: z.enum(['dark', 'light', 'system']), 27 relayMetadata: RelayMetadataSchema, 28 }) satisfies z.ZodType<AppConfig>; 29 30 export function AppProvider(props: AppProviderProps) { 31 const { 32 children, 33 storageKey, 34 defaultConfig, 35 } = props; 36 37 // App configuration state with localStorage persistence 38 const [rawConfig, setConfig] = useLocalStorage<Partial<AppConfig>>( 39 storageKey, 40 {}, 41 { 42 serialize: JSON.stringify, 43 deserialize: (value: string) => { 44 const parsed = JSON.parse(value); 45 return AppConfigSchema.partial().parse(parsed); 46 } 47 } 48 ); 49 50 // Generic config updater with callback pattern 51 const updateConfig = (updater: (currentConfig: Partial<AppConfig>) => Partial<AppConfig>) => { 52 setConfig(updater); 53 }; 54 55 const config = { ...defaultConfig, ...rawConfig }; 56 57 const appContextValue: AppContextType = { 58 config, 59 updateConfig, 60 }; 61 62 // Apply theme effects to document 63 useApplyTheme(config.theme); 64 65 return ( 66 <AppContext.Provider value={appContextValue}> 67 {children} 68 </AppContext.Provider> 69 ); 70 } 71 72 /** 73 * Hook to apply theme changes to the document root 74 */ 75 function useApplyTheme(theme: Theme) { 76 useEffect(() => { 77 const root = window.document.documentElement; 78 79 root.classList.remove('light', 'dark'); 80 81 if (theme === 'system') { 82 const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') 83 .matches 84 ? 'dark' 85 : 'light'; 86 87 root.classList.add(systemTheme); 88 return; 89 } 90 91 root.classList.add(theme); 92 }, [theme]); 93 94 // Handle system theme changes when theme is set to "system" 95 useEffect(() => { 96 if (theme !== 'system') return; 97 98 const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 99 100 const handleChange = () => { 101 const root = window.document.documentElement; 102 root.classList.remove('light', 'dark'); 103 104 const systemTheme = mediaQuery.matches ? 'dark' : 'light'; 105 root.classList.add(systemTheme); 106 }; 107 108 mediaQuery.addEventListener('change', handleChange); 109 return () => mediaQuery.removeEventListener('change', handleChange); 110 }, [theme]); 111 }