diff options
Diffstat (limited to 'packages/shared-react/hooks')
| -rw-r--r-- | packages/shared-react/hooks/reader-settings.tsx | 285 |
1 files changed, 285 insertions, 0 deletions
diff --git a/packages/shared-react/hooks/reader-settings.tsx b/packages/shared-react/hooks/reader-settings.tsx new file mode 100644 index 00000000..2705a050 --- /dev/null +++ b/packages/shared-react/hooks/reader-settings.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +import { + READER_DEFAULTS, + ReaderSettings, + ReaderSettingsPartial, +} from "@karakeep/shared/types/readers"; + +import { api } from "../trpc"; + +export interface UseReaderSettingsOptions { + /** + * Get local overrides (device-specific settings stored locally) + */ + getLocalOverrides: () => ReaderSettingsPartial; + /** + * Save local overrides to local storage + */ + saveLocalOverrides: (overrides: ReaderSettingsPartial) => void; + /** + * Optional session overrides (for live preview in web). + * If provided, these take highest precedence. + */ + sessionOverrides?: ReaderSettingsPartial; + /** + * Callback when session overrides should be cleared (after successful server save) + */ + onClearSessionOverrides?: () => void; +} + +export function useReaderSettings(options: UseReaderSettingsOptions) { + const { + getLocalOverrides, + saveLocalOverrides, + sessionOverrides = {}, + onClearSessionOverrides, + } = options; + + const [localOverrides, setLocalOverrides] = useState<ReaderSettingsPartial>( + {}, + ); + const [pendingServerSave, setPendingServerSave] = + useState<ReaderSettings | null>(null); + + const { data: serverSettings } = api.users.settings.useQuery(); + const apiUtils = api.useUtils(); + + // Load local overrides on mount + useEffect(() => { + setLocalOverrides(getLocalOverrides()); + }, [getLocalOverrides]); + + // Clear pending state when server settings match what we saved + useEffect(() => { + if (pendingServerSave && serverSettings) { + const serverMatches = + serverSettings.readerFontSize === pendingServerSave.fontSize && + // Tolerate minor float normalization differences for lineHeight + Math.abs( + (serverSettings.readerLineHeight ?? 0) - pendingServerSave.lineHeight, + ) < 1e-6 && + serverSettings.readerFontFamily === pendingServerSave.fontFamily; + if (serverMatches) { + setPendingServerSave(null); + } + } + }, [serverSettings, pendingServerSave]); + + const { mutate: updateServerSettings, isPending: isSaving } = + api.users.updateSettings.useMutation({ + onSettled: async () => { + await apiUtils.users.settings.refetch(); + }, + }); + + // Separate mutation for saving defaults (clears local overrides on success) + const { mutate: saveServerSettings, isPending: isSavingDefaults } = + api.users.updateSettings.useMutation({ + onSuccess: () => { + // Clear local and session overrides after successful server save + setLocalOverrides({}); + saveLocalOverrides({}); + onClearSessionOverrides?.(); + }, + onError: () => { + // Clear pending state so we don't show values that failed to persist + setPendingServerSave(null); + }, + onSettled: async () => { + await apiUtils.users.settings.refetch(); + }, + }); + + // Compute effective settings with precedence: session → local → pendingSave → server → default + const settings: ReaderSettings = useMemo( + () => ({ + fontSize: + sessionOverrides.fontSize ?? + localOverrides.fontSize ?? + pendingServerSave?.fontSize ?? + serverSettings?.readerFontSize ?? + READER_DEFAULTS.fontSize, + lineHeight: + sessionOverrides.lineHeight ?? + localOverrides.lineHeight ?? + pendingServerSave?.lineHeight ?? + serverSettings?.readerLineHeight ?? + READER_DEFAULTS.lineHeight, + fontFamily: + sessionOverrides.fontFamily ?? + localOverrides.fontFamily ?? + pendingServerSave?.fontFamily ?? + serverSettings?.readerFontFamily ?? + READER_DEFAULTS.fontFamily, + }), + [sessionOverrides, localOverrides, pendingServerSave, serverSettings], + ); + + // Get the server setting values (for UI indicators) + const serverDefaults: ReaderSettingsPartial = useMemo( + () => ({ + fontSize: serverSettings?.readerFontSize ?? undefined, + lineHeight: serverSettings?.readerLineHeight ?? undefined, + fontFamily: serverSettings?.readerFontFamily ?? undefined, + }), + [serverSettings], + ); + + // Update local override (per-device, immediate) + const updateLocal = useCallback( + (updates: ReaderSettingsPartial) => { + setLocalOverrides((prev) => { + const newOverrides = { ...prev, ...updates }; + saveLocalOverrides(newOverrides); + return newOverrides; + }); + }, + [saveLocalOverrides], + ); + + // Clear a specific local override + const clearLocal = useCallback( + (key: keyof ReaderSettings) => { + setLocalOverrides((prev) => { + const { [key]: _, ...rest } = prev; + saveLocalOverrides(rest); + return rest; + }); + }, + [saveLocalOverrides], + ); + + // Clear all local overrides + const clearAllLocal = useCallback(() => { + setLocalOverrides({}); + saveLocalOverrides({}); + }, [saveLocalOverrides]); + + // Save current effective settings as server default (syncs across devices) + const saveAsDefault = useCallback( + (settingsToSave?: ReaderSettingsPartial) => { + const toSave: ReaderSettings = { + fontSize: settingsToSave?.fontSize ?? settings.fontSize, + lineHeight: settingsToSave?.lineHeight ?? settings.lineHeight, + fontFamily: settingsToSave?.fontFamily ?? settings.fontFamily, + }; + // Set pending state to prevent flicker while server syncs + setPendingServerSave(toSave); + saveServerSettings({ + readerFontSize: toSave.fontSize, + readerLineHeight: toSave.lineHeight, + readerFontFamily: toSave.fontFamily, + }); + }, + [settings, saveServerSettings], + ); + + // Clear a specific server default (set to null) + const clearDefault = useCallback( + (key: keyof ReaderSettings) => { + const serverKeyMap = { + fontSize: "readerFontSize", + lineHeight: "readerLineHeight", + fontFamily: "readerFontFamily", + } as const; + updateServerSettings({ [serverKeyMap[key]]: null }); + }, + [updateServerSettings], + ); + + // Clear all server defaults + const clearAllDefaults = useCallback(() => { + updateServerSettings({ + readerFontSize: null, + readerLineHeight: null, + readerFontFamily: null, + }); + }, [updateServerSettings]); + + // Check if there are any local overrides + const hasLocalOverrides = Object.keys(localOverrides).length > 0; + + // Check if there are any server defaults + const hasServerDefaults = + serverSettings?.readerFontSize != null || + serverSettings?.readerLineHeight != null || + serverSettings?.readerFontFamily != null; + + return { + // Current effective settings (what should be displayed) + settings, + + // Raw values for UI indicators + localOverrides, + serverDefaults, + + // Status flags + hasLocalOverrides, + hasServerDefaults, + isSaving: isSaving || isSavingDefaults, + + // Internal state setters (for web's context-based approach) + setLocalOverrides, + + // Actions + updateLocal, + clearLocal, + clearAllLocal, + saveAsDefault, + clearDefault, + clearAllDefaults, + }; +} + +// Context for sharing reader settings state across components +export type ReaderSettingsContextValue = ReturnType<typeof useReaderSettings>; + +const ReaderSettingsContext = createContext<ReaderSettingsContextValue | null>( + null, +); + +export interface ReaderSettingsProviderProps extends UseReaderSettingsOptions { + children: ReactNode; +} + +/** + * Provider that creates a single instance of reader settings state + * and shares it across all child components. + */ +export function ReaderSettingsProvider({ + children, + ...options +}: ReaderSettingsProviderProps) { + const value = useReaderSettings(options); + + return ( + <ReaderSettingsContext.Provider value={value}> + {children} + </ReaderSettingsContext.Provider> + ); +} + +/** + * Hook to access shared reader settings from context. + * Must be used within a ReaderSettingsProvider. + */ +export function useReaderSettingsContext() { + const context = useContext(ReaderSettingsContext); + if (!context) { + throw new Error( + "useReaderSettingsContext must be used within a ReaderSettingsProvider", + ); + } + return context; +} |
