mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-08-28 05:29:23 +00:00
Consolidate ThemeProvider and AppProvider
This commit is contained in:
parent
bce7af2e76
commit
c3310ab485
@ -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,8 +23,7 @@ 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>
|
||||||
@ -40,7 +38,6 @@ export function App() {
|
|||||||
</NostrLoginProvider>
|
</NostrLoginProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</ThemeProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
const contextValue: AppContextType = {
|
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 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]);
|
||||||
|
}
|
@ -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("");
|
||||||
}}
|
}}
|
||||||
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
54
src/hooks/useLocalStorage.ts
Normal file
54
src/hooks/useLocalStorage.ts
Normal 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;
|
||||||
|
}
|
@ -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;
|
|
||||||
}
|
}
|
@ -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);
|
|
Loading…
x
Reference in New Issue
Block a user