/ src / components / AppProvider.tsx
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  }