Consolidate ThemeProvider and AppProvider

This commit is contained in:
Alex Gleason 2025-06-04 09:42:53 -05:00
parent bce7af2e76
commit c3310ab485
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
7 changed files with 199 additions and 145 deletions

View File

@ -3,7 +3,6 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import NostrProvider from '@/components/NostrProvider' import NostrProvider from '@/components/NostrProvider'
import { ThemeProvider } from "@/components/ThemeProvider";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner"; import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
@ -24,23 +23,21 @@ const queryClient = new QueryClient({
export function App() { export function App() {
return ( return (
<ThemeProvider defaultTheme="light" storageKey="theme"> <AppProvider defaultTheme="light">
<AppProvider> <QueryClientProvider client={queryClient}>
<QueryClientProvider client={queryClient}> <NostrLoginProvider storageKey='nostr:login'>
<NostrLoginProvider storageKey='nostr:login'> <NostrProvider>
<NostrProvider> <TooltipProvider>
<TooltipProvider> <Toaster />
<Toaster /> <Sonner />
<Sonner /> <Suspense>
<Suspense> <AppRouter />
<AppRouter /> </Suspense>
</Suspense> </TooltipProvider>
</TooltipProvider> </NostrProvider>
</NostrProvider> </NostrLoginProvider>
</NostrLoginProvider> </QueryClientProvider>
</QueryClientProvider> </AppProvider>
</AppProvider>
</ThemeProvider>
); );
} }

View File

@ -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 { interface AppConfig {
/** Current theme */
theme: Theme;
/** Selected relay URL */ /** Selected relay URL */
relayUrl: string; relayUrl: string;
} }
@ -22,64 +27,97 @@ export const RELAY_OPTIONS: RelayInfo[] = [
// Default application configuration // Default application configuration
const DEFAULT_CONFIG: AppConfig = { const DEFAULT_CONFIG: AppConfig = {
theme: 'system',
relayUrl: 'wss://relay.nostr.band', relayUrl: 'wss://relay.nostr.band',
}; };
interface AppContextType { interface AppContextType {
/** Current application configuration */ /** Current application configuration */
config: AppConfig; config: AppConfig;
/** Update any configuration value */ /** Update configuration using a callback that receives current config and returns new config */
updateConfig: <K extends keyof AppConfig>(key: K, value: AppConfig[K]) => void; updateConfig: (updater: (currentConfig: AppConfig) => AppConfig) => void;
/** Available relay options */ /** Available relay options */
availableRelays: RelayInfo[]; availableRelays: RelayInfo[];
} }
const AppContext = createContext<AppContextType | undefined>(undefined); const AppContext = createContext<AppContextType | undefined>(undefined);
const STORAGE_KEY = 'nostr:app-config'; const APP_CONFIG_STORAGE_KEY = 'nostr:app-config';
interface AppProviderProps { interface AppProviderProps {
children: ReactNode; children: ReactNode;
/** Default theme for the application */
defaultTheme?: Theme;
} }
export function AppProvider({ children }: AppProviderProps) { export function AppProvider({
const [config, setConfig] = useState<AppConfig>(DEFAULT_CONFIG); children,
defaultTheme = 'system'
// Load saved config from localStorage on mount }: 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(() => { useEffect(() => {
try { const root = window.document.documentElement;
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);
}
}, []);
// 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(() => { useEffect(() => {
try { if (theme !== 'system') return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch (error) {
console.warn('Failed to save app config to localStorage:', error);
}
}, [config]);
// Generic config updater const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const updateConfig = <K extends keyof AppConfig>(key: K, value: AppConfig[K]) => {
setConfig(prev => ({ ...prev, [key]: value })); 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, config,
updateConfig, updateConfig,
availableRelays: RELAY_OPTIONS, 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; 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]);
}

View File

@ -46,7 +46,7 @@ export function RelaySelector({ className }: RelaySelectorProps) {
const handleAddCustomRelay = (url: string) => { const handleAddCustomRelay = (url: string) => {
const normalizedUrl = normalizeRelayUrl(url); const normalizedUrl = normalizeRelayUrl(url);
if (normalizedUrl) { if (normalizedUrl) {
updateConfig("relayUrl", normalizedUrl); updateConfig(config => ({ ...config, relayUrl: normalizedUrl }));
setOpen(false); setOpen(false);
setInputValue(""); setInputValue("");
} }
@ -130,7 +130,7 @@ export function RelaySelector({ className }: RelaySelectorProps) {
key={option.url} key={option.url}
value={option.url} value={option.url}
onSelect={(currentValue) => { onSelect={(currentValue) => {
updateConfig("relayUrl", currentValue); updateConfig(config => ({ ...config, relayUrl: currentValue }));
setOpen(false); setOpen(false);
setInputValue(""); setInputValue("");
}} }}

View File

@ -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<Theme>(
() => (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 (
<ThemeContext.Provider {...props} value={value}>
{children}
</ThemeContext.Provider>
)
}

View File

@ -0,0 +1,54 @@
import { useState, useEffect } from 'react';
/**
* Generic hook for managing localStorage state
*/
export function useLocalStorage<T>(
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<T>(() => {
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;
}

View File

@ -1,13 +1,6 @@
import { useContext } from "react" import { useTheme as useThemeFromProvider } from "@/components/AppProvider";
import { ThemeContext, type ThemeContextType } from "@/lib/ThemeContext"
/** Hook to get and set the active theme. */ /** Hook to get and set the active theme. */
export function useTheme(): ThemeContextType { export function useTheme() {
const context = useContext(ThemeContext); return useThemeFromProvider();
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
} }

View File

@ -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<ThemeContextType>(initialState);