From c3310ab4857c76c693b834ada6ad6a8ba5ad0dd4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 4 Jun 2025 09:42:53 -0500 Subject: [PATCH] Consolidate ThemeProvider and AppProvider --- src/App.tsx | 33 +++---- src/components/AppProvider.ts | 156 +++++++++++++++++++++++++------ src/components/RelaySelector.tsx | 4 +- src/components/ThemeProvider.tsx | 69 -------------- src/hooks/useLocalStorage.ts | 54 +++++++++++ src/hooks/useTheme.ts | 13 +-- src/lib/ThemeContext.ts | 15 --- 7 files changed, 199 insertions(+), 145 deletions(-) delete mode 100644 src/components/ThemeProvider.tsx create mode 100644 src/hooks/useLocalStorage.ts delete mode 100644 src/lib/ThemeContext.ts diff --git a/src/App.tsx b/src/App.tsx index 7e18712..f8d0a19 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,6 @@ import { Suspense } from 'react'; import NostrProvider from '@/components/NostrProvider' -import { ThemeProvider } from "@/components/ThemeProvider"; import { Toaster } from "@/components/ui/toaster"; import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -24,23 +23,21 @@ const queryClient = new QueryClient({ export function App() { return ( - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); } diff --git a/src/components/AppProvider.ts b/src/components/AppProvider.ts index ee74f9e..a765c88 100644 --- a/src/components/AppProvider.ts +++ b/src/components/AppProvider.ts @@ -1,6 +1,11 @@ -import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import React, { createContext, useContext, ReactNode, useEffect } from 'react'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; + +export type Theme = "dark" | "light" | "system"; interface AppConfig { + /** Current theme */ + theme: Theme; /** Selected relay URL */ relayUrl: string; } @@ -22,64 +27,97 @@ export const RELAY_OPTIONS: RelayInfo[] = [ // Default application configuration const DEFAULT_CONFIG: AppConfig = { + theme: 'system', relayUrl: 'wss://relay.nostr.band', }; interface AppContextType { /** Current application configuration */ config: AppConfig; - /** Update any configuration value */ - updateConfig: (key: K, value: AppConfig[K]) => void; + /** Update configuration using a callback that receives current config and returns new config */ + updateConfig: (updater: (currentConfig: AppConfig) => AppConfig) => void; /** Available relay options */ availableRelays: RelayInfo[]; } const AppContext = createContext(undefined); -const STORAGE_KEY = 'nostr:app-config'; +const APP_CONFIG_STORAGE_KEY = 'nostr:app-config'; interface AppProviderProps { children: ReactNode; + /** Default theme for the application */ + defaultTheme?: Theme; } -export function AppProvider({ children }: AppProviderProps) { - const [config, setConfig] = useState(DEFAULT_CONFIG); - - // Load saved config from localStorage on mount +export function AppProvider({ + children, + defaultTheme = 'system' +}: AppProviderProps) { + // App configuration state with localStorage persistence + const [config, setConfig] = useLocalStorage( + APP_CONFIG_STORAGE_KEY, + { ...DEFAULT_CONFIG, theme: defaultTheme } + ); +/** + * Hook to apply theme changes to the document root + */ +function useThemeEffect(theme: Theme) { useEffect(() => { - try { - const savedConfig = localStorage.getItem(STORAGE_KEY); - if (savedConfig) { - const parsed = JSON.parse(savedConfig); - // Merge with defaults to handle new config options - setConfig(prev => ({ ...prev, ...parsed })); - } - } catch (error) { - console.warn('Failed to load app config from localStorage:', error); - } - }, []); + const root = window.document.documentElement; - // Save config to localStorage when it changes + root.classList.remove('light', 'dark'); + + if (theme === 'system') { + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') + .matches + ? 'dark' + : 'light'; + + root.classList.add(systemTheme); + return; + } + + root.classList.add(theme); + }, [theme]); + + // Handle system theme changes when theme is set to "system" useEffect(() => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); - } catch (error) { - console.warn('Failed to save app config to localStorage:', error); - } - }, [config]); + if (theme !== 'system') return; - // Generic config updater - const updateConfig = (key: K, value: AppConfig[K]) => { - setConfig(prev => ({ ...prev, [key]: value })); + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleChange = () => { + const root = window.document.documentElement; + root.classList.remove('light', 'dark'); + + const systemTheme = mediaQuery.matches ? 'dark' : 'light'; + root.classList.add(systemTheme); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, [theme]); +} + // Apply theme effects to document + useThemeEffect(config.theme); + + // Generic config updater with callback pattern + const updateConfig = (updater: (currentConfig: AppConfig) => AppConfig) => { + setConfig(updater); }; - const contextValue: AppContextType = { + const appContextValue: AppContextType = { config, updateConfig, availableRelays: RELAY_OPTIONS, }; - return React.createElement(AppContext.Provider, { value: contextValue }, children); + return React.createElement( + AppContext.Provider, + { value: appContextValue }, + children + ); } /** @@ -93,3 +131,59 @@ export function useAppConfig() { } return context; } + +/** + * Hook to get and set the active theme + * @returns Theme context with theme and setTheme + */ +export function useTheme() { + const context = useContext(AppContext); + if (context === undefined) { + throw new Error('useTheme must be used within an AppProvider'); + } + return { + theme: context.config.theme, + setTheme: (theme: Theme) => context.updateConfig(config => ({ ...config, theme })), + }; +} + +/** + * Hook to apply theme changes to the document root + */ +export function useThemeEffect(theme: Theme) { + useEffect(() => { + const root = window.document.documentElement; + + root.classList.remove('light', 'dark'); + + if (theme === 'system') { + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') + .matches + ? 'dark' + : 'light'; + + root.classList.add(systemTheme); + return; + } + + root.classList.add(theme); + }, [theme]); + + // Handle system theme changes when theme is set to "system" + useEffect(() => { + if (theme !== 'system') return; + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleChange = () => { + const root = window.document.documentElement; + root.classList.remove('light', 'dark'); + + const systemTheme = mediaQuery.matches ? 'dark' : 'light'; + root.classList.add(systemTheme); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, [theme]); +} \ No newline at end of file diff --git a/src/components/RelaySelector.tsx b/src/components/RelaySelector.tsx index ffe07c3..ae7f4f5 100644 --- a/src/components/RelaySelector.tsx +++ b/src/components/RelaySelector.tsx @@ -46,7 +46,7 @@ export function RelaySelector({ className }: RelaySelectorProps) { const handleAddCustomRelay = (url: string) => { const normalizedUrl = normalizeRelayUrl(url); if (normalizedUrl) { - updateConfig("relayUrl", normalizedUrl); + updateConfig(config => ({ ...config, relayUrl: normalizedUrl })); setOpen(false); setInputValue(""); } @@ -130,7 +130,7 @@ export function RelaySelector({ className }: RelaySelectorProps) { key={option.url} value={option.url} onSelect={(currentValue) => { - updateConfig("relayUrl", currentValue); + updateConfig(config => ({ ...config, relayUrl: currentValue })); setOpen(false); setInputValue(""); }} diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx deleted file mode 100644 index 04c778c..0000000 --- a/src/components/ThemeProvider.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useEffect, useState } from "react" -import { type Theme, ThemeContext } from "@/lib/ThemeContext" - -type ThemeProviderProps = { - children: React.ReactNode; - defaultTheme: Theme; - storageKey: string; -} - -export function ThemeProvider({ - children, - defaultTheme, - storageKey, - ...props -}: ThemeProviderProps) { - const [theme, setTheme] = useState( - () => (localStorage.getItem(storageKey) as Theme) || defaultTheme - ) - - useEffect(() => { - const root = window.document.documentElement - - root.classList.remove("light", "dark") - - if (theme === "system") { - const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") - .matches - ? "dark" - : "light" - - root.classList.add(systemTheme) - return - } - - root.classList.add(theme) - }, [theme]) - - useEffect(() => { - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - - const handleChange = () => { - if (theme === "system") { - const root = window.document.documentElement - root.classList.remove("light", "dark") - - const systemTheme = mediaQuery.matches ? "dark" : "light" - root.classList.add(systemTheme) - } - } - - mediaQuery.addEventListener("change", handleChange) - return () => mediaQuery.removeEventListener("change", handleChange) - }, [theme]) - - const value = { - theme, - setTheme: (theme: Theme) => { - localStorage.setItem(storageKey, theme) - setTheme(theme) - }, - } - - return ( - - {children} - - ) -} - diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..73c2959 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,54 @@ +import { useState, useEffect } from 'react'; + +/** + * Generic hook for managing localStorage state + */ +export function useLocalStorage( + key: string, + defaultValue: T, + serializer?: { + serialize: (value: T) => string; + deserialize: (value: string) => T; + } +) { + const serialize = serializer?.serialize || JSON.stringify; + const deserialize = serializer?.deserialize || JSON.parse; + + const [state, setState] = useState(() => { + try { + const item = localStorage.getItem(key); + return item ? deserialize(item) : defaultValue; + } catch (error) { + console.warn(`Failed to load ${key} from localStorage:`, error); + return defaultValue; + } + }); + + const setValue = (value: T | ((prev: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(state) : value; + setState(valueToStore); + localStorage.setItem(key, serialize(valueToStore)); + } catch (error) { + console.warn(`Failed to save ${key} to localStorage:`, error); + } + }; + + // Sync with localStorage changes from other tabs + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === key && e.newValue !== null) { + try { + setState(deserialize(e.newValue)); + } catch (error) { + console.warn(`Failed to sync ${key} from localStorage:`, error); + } + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => window.removeEventListener('storage', handleStorageChange); + }, [key, deserialize]); + + return [state, setValue] as const; +} \ No newline at end of file diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index be0c18d..31c692a 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -1,13 +1,6 @@ -import { useContext } from "react" -import { ThemeContext, type ThemeContextType } from "@/lib/ThemeContext" +import { useTheme as useThemeFromProvider } from "@/components/AppProvider"; /** Hook to get and set the active theme. */ -export function useTheme(): ThemeContextType { - const context = useContext(ThemeContext); - - if (!context) { - throw new Error("useTheme must be used within a ThemeProvider"); - } - - return context; +export function useTheme() { + return useThemeFromProvider(); } \ No newline at end of file diff --git a/src/lib/ThemeContext.ts b/src/lib/ThemeContext.ts deleted file mode 100644 index 902edf9..0000000 --- a/src/lib/ThemeContext.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createContext } from "react"; - -export type Theme = "dark" | "light" | "system"; - -export type ThemeContextType = { - theme: Theme; - setTheme: (theme: Theme) => void; -}; - -const initialState: ThemeContextType = { - theme: "system", - setTheme: () => undefined, -}; - -export const ThemeContext = createContext(initialState); \ No newline at end of file