/** * useReadingSettings — fetch and manage user reading preferences. * Preview applies instantly to the reader; API saves are debounced for sliders. */ import { useCallback, useEffect, useRef, useState } from "react"; import { getReadingSettings, updateReadingSettings, } from "../api/reader"; import type { ReadingSettings } from "../types/reader"; const THEME_COLORS: Record< ReadingSettings["theme"], Pick > = { sepia: { background_color: "#f5f0eb", text_color: "#1a1a1a" }, dark: { background_color: "#1a1a2e", text_color: "#e0e0e0" }, light: { background_color: "#ffffff", text_color: "#1a1a1a" }, paper: { background_color: "#e8e0d4", text_color: "#2c2c2c" }, }; const DEFAULT_SETTINGS: ReadingSettings = { font_family: "serif", font_size: 18, line_height: 1.6, margin_width: 16, background_color: "#f5f0eb", text_color: "#1a1a1a", brightness: 100, orientation_lock: "auto", theme: "sepia", created_at: "", updated_at: "", }; const SAVE_DEBOUNCE_MS = 600; function applyReadingCssVariables(settings: ReadingSettings): void { const root = document.documentElement; root.style.setProperty("--reader-bg", settings.background_color); root.style.setProperty("--reader-text", settings.text_color); root.style.setProperty("--reader-margin", `${settings.margin_width}px`); root.style.setProperty("--reader-brightness", `${settings.brightness}%`); } function mergeSettings( prev: ReadingSettings, partial: Partial, ): ReadingSettings { const next = { ...prev, ...partial }; if (partial.theme && THEME_COLORS[partial.theme]) { Object.assign(next, THEME_COLORS[partial.theme]); } return next; } export type SettingsPersistMode = "debounced" | "immediate"; export interface UseReadingSettingsReturn { settings: ReadingSettings; isLoading: boolean; error: string | null; updateSettings: ( partial: Partial, persist?: SettingsPersistMode, ) => void; flushSettings: () => Promise; } export function useReadingSettings(): UseReadingSettingsReturn { const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const pendingPatch = useRef>({}); const saveTimer = useRef | null>(null); const saving = useRef(false); const runPersist = useCallback(async () => { const payload = { ...pendingPatch.current }; pendingPatch.current = {}; if (Object.keys(payload).length === 0 || saving.current) return; saving.current = true; try { const updated = await updateReadingSettings(payload); setSettings(updated); applyReadingCssVariables(updated); setError(null); } catch (err: unknown) { setError( err instanceof Error ? err.message : "Failed to update reading settings", ); } finally { saving.current = false; } }, []); const schedulePersist = useCallback( (partial: Partial, immediate: boolean) => { Object.assign(pendingPatch.current, partial); if (saveTimer.current) clearTimeout(saveTimer.current); if (immediate) { void runPersist(); return; } saveTimer.current = setTimeout(() => { void runPersist(); }, SAVE_DEBOUNCE_MS); }, [runPersist], ); useEffect(() => { let cancelled = false; setIsLoading(true); getReadingSettings() .then((data) => { if (!cancelled) { setSettings(data); applyReadingCssVariables(data); } }) .catch((err: unknown) => { if (!cancelled) { setError( err instanceof Error ? err.message : "Failed to load reading settings", ); applyReadingCssVariables(DEFAULT_SETTINGS); } }) .finally(() => { if (!cancelled) setIsLoading(false); }); return () => { cancelled = true; if (saveTimer.current) clearTimeout(saveTimer.current); }; }, []); const updateSettings = useCallback( (partial: Partial, persist: SettingsPersistMode = "debounced") => { setSettings((prev) => { const next = mergeSettings(prev, partial); applyReadingCssVariables(next); return next; }); schedulePersist(partial, persist === "immediate"); }, [schedulePersist], ); const flushSettings = useCallback(async () => { if (saveTimer.current) clearTimeout(saveTimer.current); await runPersist(); }, [runPersist]); return { settings, isLoading, error, updateSettings, flushSettings }; }