Archived
161 lines
4.7 KiB
TypeScript
161 lines
4.7 KiB
TypeScript
/**
|
|
* 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<ReadingSettings, "background_color" | "text_color">
|
|
> = {
|
|
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>,
|
|
): 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<ReadingSettings>,
|
|
persist?: SettingsPersistMode,
|
|
) => void;
|
|
flushSettings: () => Promise<void>;
|
|
}
|
|
|
|
export function useReadingSettings(): UseReadingSettingsReturn {
|
|
const [settings, setSettings] = useState<ReadingSettings>(DEFAULT_SETTINGS);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const pendingPatch = useRef<Partial<ReadingSettings>>({});
|
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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<ReadingSettings>, 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<ReadingSettings>, 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 };
|
|
}
|