openreader/src/contexts/ConfigContext.tsx
Richard Roberson 7271afa2f5 refactor(config): centralize app config in Dexie
Migrates scattered configuration items and last document locations
from the legacy `config` table to new, dedicated Dexie stores.

- Introduces a singleton `app-config` table (`AppConfigRow`) to standardize
  and structure all application settings in a single object.
- Creates a `last-locations` table for efficient storage of document read positions.
- Updates `ConfigContext` to utilize `dexie-react-hooks`' `useLiveQuery`
  for reactive and simplified state management.
- Implements a database upgrade path (from DB_VERSION 4 to 5) to migrate
  all existing user settings seamlessly.
- Simplifies config access and updates with new `getAppConfig` and `updateAppConfig`
  utility functions.
- Removes the deprecated `config` table after successful migration.
- Updates consumers like `SettingsModal` and test helpers to align with
  the new config structure.
2025-11-15 17:29:56 -07:00

220 lines
6.3 KiB
TypeScript

'use client';
import { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db, initDB, updateAppConfig } from '@/utils/dexie';
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/appConfig';
export type { ViewType } from '@/types/appConfig';
/** Configuration values for the application */
type ConfigValues = Omit<AppConfigRow, 'id'>;
/** Interface defining the configuration context shape and functionality */
interface ConfigContextType {
apiKey: string;
baseUrl: string;
viewType: ViewType;
voiceSpeed: number;
audioPlayerSpeed: number;
voice: string;
skipBlank: boolean;
epubTheme: boolean;
smartSentenceSplitting: boolean;
headerMargin: number;
footerMargin: number;
leftMargin: number;
rightMargin: number;
ttsProvider: string;
ttsModel: string;
ttsInstructions: string;
savedVoices: SavedVoices;
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
updateConfigKey: <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => Promise<void>;
isLoading: boolean;
isDBReady: boolean;
pdfHighlightEnabled: boolean;
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
/**
* Provider component for application configuration
* Manages global configuration state and persistence
* @param {Object} props - Component props
* @param {ReactNode} props.children - Child components to be wrapped by the provider
*/
export function ConfigProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(true);
const [isDBReady, setIsDBReady] = useState(false);
// Helper function to generate provider-model key
const getVoiceKey = (provider: string, model: string) => `${provider}:${model}`;
useEffect(() => {
const initializeDB = async () => {
try {
setIsLoading(true);
await initDB();
setIsDBReady(true);
} catch (error) {
console.error('Error initializing Dexie:', error);
} finally {
setIsLoading(false);
}
};
initializeDB();
}, []);
const appConfig = useLiveQuery(
async () => {
if (!isDBReady) return null;
const row = await db['app-config'].get('singleton');
return row ?? null;
},
[isDBReady],
null,
);
const config: ConfigValues | null = useMemo(() => {
if (!appConfig) return null;
const { id, ...rest } = appConfig;
void id;
return rest;
}, [appConfig]);
// Destructure for convenience and to match context shape
const {
apiKey,
baseUrl,
viewType,
voiceSpeed,
audioPlayerSpeed,
voice,
skipBlank,
epubTheme,
headerMargin,
footerMargin,
leftMargin,
rightMargin,
ttsProvider,
ttsModel,
ttsInstructions,
savedVoices,
smartSentenceSplitting,
pdfHighlightEnabled,
} = config || APP_CONFIG_DEFAULTS;
/**
* Updates multiple configuration values simultaneously
* Only saves API credentials if they are explicitly set
*/
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => {
try {
setIsLoading(true);
const updates: Partial<AppConfigRow> = {};
if (newConfig.apiKey !== undefined) {
updates.apiKey = newConfig.apiKey;
}
if (newConfig.baseUrl !== undefined) {
updates.baseUrl = newConfig.baseUrl;
}
await updateAppConfig(updates);
} catch (error) {
console.error('Error updating config:', error);
throw error;
} finally {
setIsLoading(false);
}
};
/**
* Updates a single configuration value by key
* @param {K} key - The configuration key to update
* @param {ConfigValues[K]} value - The new value for the configuration
*/
const updateConfigKey = async <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => {
try {
setIsLoading(true);
// Special handling for voice - only update savedVoices
if (key === 'voice') {
const voiceKey = getVoiceKey(ttsProvider, ttsModel);
const updatedSavedVoices = { ...savedVoices, [voiceKey]: value as string };
await updateAppConfig({
savedVoices: updatedSavedVoices,
voice: value as string,
});
}
// Special handling for provider/model changes - restore saved voice if available
else if (key === 'ttsProvider' || key === 'ttsModel') {
const newProvider = key === 'ttsProvider' ? (value as string) : ttsProvider;
const newModel = key === 'ttsModel' ? (value as string) : ttsModel;
const voiceKey = getVoiceKey(newProvider, newModel);
const restoredVoice = savedVoices[voiceKey] || '';
await updateAppConfig({
[key]: value as ConfigValues[keyof ConfigValues],
voice: restoredVoice,
} as Partial<AppConfigRow>);
}
else if (key === 'savedVoices') {
const newSavedVoices = value as SavedVoices;
await updateAppConfig({
savedVoices: newSavedVoices,
});
}
else {
await updateAppConfig({
[key]: value as ConfigValues[keyof ConfigValues],
} as Partial<AppConfigRow>);
}
} catch (error) {
console.error(`Error updating config key ${String(key)}:`, error);
throw error;
} finally {
setIsLoading(false);
}
};
return (
<ConfigContext.Provider value={{
apiKey,
baseUrl,
viewType,
voiceSpeed,
audioPlayerSpeed,
voice,
skipBlank,
epubTheme,
smartSentenceSplitting,
headerMargin,
footerMargin,
leftMargin,
rightMargin,
ttsProvider,
ttsModel,
ttsInstructions,
savedVoices,
updateConfig,
updateConfigKey,
isLoading,
isDBReady,
pdfHighlightEnabled
}}>
{children}
</ConfigContext.Provider>
);
}
/**
* Custom hook to consume the configuration context
* @returns {ConfigContextType} The configuration context value
* @throws {Error} When used outside of ConfigProvider
*/
export function useConfig() {
const context = useContext(ConfigContext);
if (context === undefined) {
throw new Error('useConfig must be used within a ConfigProvider');
}
return context;
}