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 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 (
<ThemeProvider defaultTheme="light" storageKey="theme">
<AppProvider>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='nostr:login'>
<NostrProvider>
<TooltipProvider>
<Toaster />
<Sonner />
<Suspense>
<AppRouter />
</Suspense>
</TooltipProvider>
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>
</AppProvider>
</ThemeProvider>
<AppProvider defaultTheme="light">
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='nostr:login'>
<NostrProvider>
<TooltipProvider>
<Toaster />
<Sonner />
<Suspense>
<AppRouter />
</Suspense>
</TooltipProvider>
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>
</AppProvider>
);
}

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 {
/** 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: <K extends keyof AppConfig>(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<AppContextType | undefined>(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<AppConfig>(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 = <K extends keyof AppConfig>(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]);
}

View File

@ -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("");
}}

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 { 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();
}

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);