'use client'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config'; import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state'; import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state'; import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences'; import { applyConfigUpdate } from '@/lib/client/config/updates'; import toast from 'react-hot-toast'; export type { ViewType } from '@/types/config'; /** Configuration values for the application */ /** 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; updateConfigKey: (key: K, value: AppConfigValues[K]) => Promise; isLoading: boolean; isDBReady: boolean; pdfHighlightEnabled: boolean; pdfWordHighlightEnabled: boolean; epubHighlightEnabled: boolean; epubWordHighlightEnabled: boolean; } const ConfigContext = createContext(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); const ttsProvidersTabDisabled = process.env.NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB === 'false'; const didRunStartupMigrations = useRef(false); const didAttemptInitialPreferenceSeedForSession = useRef(null); const syncedPreferenceKeys = useMemo(() => new Set(SYNCED_PREFERENCE_KEYS), []); const { authEnabled } = useAuthConfig(); const { data: sessionData, isPending: isSessionPending } = useAuthSession(); const sessionKey = sessionData?.user?.id ?? 'no-session'; const queueSyncedPreferencePatch = useCallback((patch: Partial) => { if (!authEnabled || sessionKey === 'no-session') return; const syncedPatch: SyncedPreferencesPatch = {}; for (const key of SYNCED_PREFERENCE_KEYS) { if (!(key in patch)) continue; const value = patch[key]; if (value === undefined) continue; (syncedPatch as Record)[key] = value; } if (Object.keys(syncedPatch).length === 0) return; scheduleUserPreferencesSync(syncedPatch, sessionKey); }, [authEnabled, sessionKey]); // Cancel pending/in-flight preference syncs whenever the session changes or on unmount. useEffect(() => { return () => { cancelPendingPreferenceSync(); }; }, [sessionKey]); useEffect(() => { const handler = (event: Event) => { const detail = (event as CustomEvent<{ status?: string; ms?: number }>).detail; const status = detail?.status; if (status === 'opened') { toast.dismiss('dexie-blocked'); return; } if (status === 'blocked' || status === 'stalled') { const message = 'Database upgrade is waiting for another OpenReader tab. Close other OpenReader tabs and reload.'; toast.error(message, { id: 'dexie-blocked', duration: Infinity }); } }; window.addEventListener('openreader:dexie', handler as EventListener); return () => { window.removeEventListener('openreader:dexie', handler as EventListener); }; }, []); useEffect(() => { const initializeDB = async () => { try { setIsLoading(true); await initDB(); setIsDBReady(true); } catch (error) { console.error('Error initializing Dexie:', error); } finally { setIsLoading(false); } }; initializeDB(); }, []); useEffect(() => { if (!isDBReady) return; if (didRunStartupMigrations.current) return; didRunStartupMigrations.current = true; const run = async () => { try { await migrateLegacyDexieDocumentIdsToSha(); } catch (error) { console.warn('Startup migrations failed:', error); } }; void run(); }, [isDBReady]); const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => { if (!isDBReady || !authEnabled) return; try { const remote = await getUserPreferences({ signal }); if (!remote?.hasStoredPreferences) return; if (!remote.preferences || Object.keys(remote.preferences).length === 0) return; await updateAppConfig(remote.preferences as Partial); } catch (error) { if ((error as Error)?.name === 'AbortError') return; console.warn('Failed to load synced preferences:', error); } }, [isDBReady, authEnabled]); useEffect(() => { if (!isDBReady || !authEnabled || isSessionPending) return; const controller = new AbortController(); refreshSyncedPreferencesFromServer(controller.signal).catch((error) => { if ((error as Error)?.name === 'AbortError') return; console.warn('Synced preferences refresh failed:', error); }); return () => controller.abort(); }, [isDBReady, authEnabled, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]); useEffect(() => { if (!isDBReady || !authEnabled) return; let activeController: AbortController | null = null; const onFocus = () => { if (activeController) activeController.abort(); activeController = new AbortController(); refreshSyncedPreferencesFromServer(activeController.signal).catch((error) => { if ((error as Error)?.name === 'AbortError') return; console.warn('Focus synced preferences refresh failed:', error); }); }; window.addEventListener('focus', onFocus); return () => { window.removeEventListener('focus', onFocus); if (activeController) activeController.abort(); }; }, [isDBReady, authEnabled, refreshSyncedPreferencesFromServer]); const appConfig = useLiveQuery( async () => { if (!isDBReady) return null; const row = await db['app-config'].get('singleton'); return row ?? null; }, [isDBReady], null, ); const config: AppConfigValues | null = useMemo(() => { if (!appConfig) return null; const { id, ...rest } = appConfig; void id; return { ...APP_CONFIG_DEFAULTS, ...rest }; }, [appConfig]); useEffect(() => { if (!ttsProvidersTabDisabled || !isDBReady || !appConfig) return; const resetPatch: Partial = {}; if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) { resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey; } if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) { resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl; } if (appConfig.ttsProvider !== APP_CONFIG_DEFAULTS.ttsProvider) { resetPatch.ttsProvider = APP_CONFIG_DEFAULTS.ttsProvider; } if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) { resetPatch.ttsModel = APP_CONFIG_DEFAULTS.ttsModel; } if (appConfig.ttsInstructions !== APP_CONFIG_DEFAULTS.ttsInstructions) { resetPatch.ttsInstructions = APP_CONFIG_DEFAULTS.ttsInstructions; } // Keep voice selection state intact so player/Audiobook voice pickers still // work when the TTS providers tab is hidden. This reset is only for provider // configuration fields. if (Object.keys(resetPatch).length === 0) return; updateAppConfig(resetPatch).catch((error) => { console.warn('Failed to clear hidden TTS provider settings:', error); }); queueSyncedPreferencePatch(resetPatch); }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch]); useEffect(() => { if (!isDBReady || !authEnabled || !appConfig || isSessionPending) return; if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return; didAttemptInitialPreferenceSeedForSession.current = sessionKey; const controller = new AbortController(); const run = async () => { try { const remote = await getUserPreferences({ signal: controller.signal }); if (remote?.hasStoredPreferences) return; // Seed only user-customized (non-default) values. This prevents fresh/default // profiles from overwriting existing server values during first-run races. const patch = buildSyncedPreferencePatch(appConfig, { nonDefaultOnly: true }); if (Object.keys(patch).length === 0) return; await putUserPreferences(patch, { clientUpdatedAtMs: Date.now(), signal: controller.signal }); } catch (error) { if ((error as Error)?.name === 'AbortError') return; console.warn('Failed to seed initial synced preferences from local Dexie:', error); } }; run().catch((error) => { if ((error as Error)?.name === 'AbortError') return; console.warn('Initial synced preferences seed failed:', error); }); return () => controller.abort(); }, [isDBReady, authEnabled, appConfig, buildSyncedPreferencePatch, isSessionPending, sessionKey]); // 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, pdfWordHighlightEnabled, epubHighlightEnabled, epubWordHighlightEnabled, } = 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 = {}; if (newConfig.apiKey !== undefined) { updates.apiKey = newConfig.apiKey; } if (newConfig.baseUrl !== undefined) { updates.baseUrl = newConfig.baseUrl; } if (newConfig.viewType !== undefined) { updates.viewType = newConfig.viewType; } await updateAppConfig(updates); queueSyncedPreferencePatch(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 {AppConfigValues[K]} value - The new value for the configuration */ const updateConfigKey = async (key: K, value: AppConfigValues[K]) => { try { setIsLoading(true); const { storagePatch, syncPatch } = applyConfigUpdate({ ttsProvider, ttsModel, savedVoices, }, key, value); await updateAppConfig(storagePatch); if (key === 'voice' || key === 'ttsProvider' || key === 'ttsModel' || key === 'savedVoices') { queueSyncedPreferencePatch(syncPatch); } else if (syncedPreferenceKeys.has(String(key))) { queueSyncedPreferencePatch(syncPatch); } } catch (error) { console.error(`Error updating config key ${String(key)}:`, error); throw error; } finally { setIsLoading(false); } }; return ( {children} ); } /** * 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; }