feat: uv config other feats

- add uv configuration for the backend
- update frontend to make auth work
- add new auth endpoints
- add bookmars feat
- add reader feat
This commit is contained in:
2026-06-03 22:06:01 -05:00
parent 730c748f5f
commit 6b4c0c43f8
137 changed files with 20319 additions and 2340 deletions
-129
View File
@@ -1,129 +0,0 @@
/**
* useChapters — fetch chapter list and manage current chapter navigation.
*/
import { useCallback, useEffect, useState } from "react";
import { getChapterContent, getChapters } from "../api/reader";
import type { ChapterDetail, ChapterSummary } from "../types/reader";
export interface UseChaptersReturn {
chapters: ChapterSummary[];
currentChapter: ChapterDetail | null;
currentChapterNumber: number;
isLoading: boolean;
error: string | null;
navigateToChapter: (number: number) => Promise<void>;
goToNextChapter: () => Promise<void>;
goToPreviousChapter: () => Promise<void>;
hasNext: boolean;
hasPrevious: boolean;
}
export function useChapters(
bookId: number,
initialChapter: number = 1
): UseChaptersReturn {
const [chapters, setChapters] = useState<ChapterSummary[]>([]);
const [currentChapter, setCurrentChapter] = useState<ChapterDetail | null>(
null
);
const [currentChapterNumber, setCurrentChapterNumber] =
useState<number>(initialChapter);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch chapter list on mount
useEffect(() => {
let cancelled = false;
setIsLoading(true);
getChapters(bookId)
.then((data) => {
if (!cancelled) {
setChapters(data);
// If chapters exist and initial chapter is valid, fetch it
if (
data.length > 0 &&
data.some((c) => c.number === currentChapterNumber)
) {
return getChapterContent(bookId, currentChapterNumber);
}
return null;
}
return null;
})
.then((chapter) => {
if (!cancelled && chapter) {
setCurrentChapter(chapter);
}
})
.catch((err: unknown) => {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to load chapters"
);
}
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [bookId, currentChapterNumber]);
const fetchChapter = useCallback(
async (number: number) => {
setIsLoading(true);
setError(null);
try {
const chapter = await getChapterContent(bookId, number);
setCurrentChapter(chapter);
setCurrentChapterNumber(number);
} catch (err: unknown) {
setError(
err instanceof Error ? err.message : "Failed to load chapter"
);
} finally {
setIsLoading(false);
}
},
[bookId]
);
const navigateToChapter = useCallback(
(number: number) => {
fetchChapter(number);
},
[fetchChapter]
);
const goToNextChapter = useCallback(() => {
const next = currentChapterNumber + 1;
if (chapters.some((c) => c.number === next)) {
fetchChapter(next);
}
}, [currentChapterNumber, chapters, fetchChapter]);
const goToPreviousChapter = useCallback(() => {
const prev = currentChapterNumber - 1;
if (prev >= 1 && chapters.some((c) => c.number === prev)) {
fetchChapter(prev);
}
}, [currentChapterNumber, chapters, fetchChapter]);
const hasNext = chapters.some((c) => c.number === currentChapterNumber + 1);
const hasPrevious = chapters.some((c) => c.number === currentChapterNumber - 1);
return {
chapters,
currentChapter,
currentChapterNumber,
isLoading,
error,
navigateToChapter,
goToNextChapter,
goToPreviousChapter,
hasNext,
hasPrevious,
};
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useMemo, useRef } from "react";
import { useAnnotations } from "@/context/AnnotationsContext";
import type { EpubRendition } from "@/utils/epubRendition";
import {
syncBookmarkHighlights,
type EpubRenditionWithHighlights,
} from "@/utils/epubHighlights";
export function useEpubHighlights(
ebookId: number,
renditionRef: React.RefObject<EpubRendition | null>,
renditionVersion: number,
): void {
const { markers, loadBookmarks } = useAnnotations();
const appliedRef = useRef<Map<string, string>>(new Map());
const bookMarkers = useMemo(
() => markers.filter((m) => m.ebook_id === ebookId),
[markers, ebookId],
);
useEffect(() => {
if (!ebookId || Number.isNaN(ebookId)) return;
void loadBookmarks(ebookId);
}, [ebookId, loadBookmarks]);
useEffect(() => {
appliedRef.current.clear();
}, [ebookId, renditionVersion]);
useEffect(() => {
const rendition = renditionRef.current as EpubRenditionWithHighlights | null;
if (!rendition) return;
const sync = () => {
syncBookmarkHighlights(rendition, bookMarkers, appliedRef.current);
};
sync();
const onRelocated = () => sync();
rendition.on?.("relocated", onRelocated);
rendition.on?.("rendered", onRelocated);
return () => {
rendition.off?.("relocated", onRelocated);
rendition.off?.("rendered", onRelocated);
};
}, [bookMarkers, renditionRef, renditionVersion]);
}
+393
View File
@@ -0,0 +1,393 @@
/**
* useEpubReader — load EPUB blob, manage CFI location, and persist reading progress.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { booksApi } from "../api/books";
import { getReadingProgress, updateReadingProgress } from "../api/reader";
import type { ReadingProgress, ReadingSettings } from "../types/reader";
import {
applyRenditionSettings,
isEpubCfi,
marginWidthToGap,
resolveSpineHref,
updateChapterTitleFromRendition,
type EpubRendition,
} from "../utils/epubRendition";
type EpubRenditionWithManager = EpubRendition & {
hooks?: {
content?: {
register: (fn: (contents: { document: Document }) => void) => void;
};
};
};
export interface ReadingAnchor {
cfi: string;
percentage: number;
}
function percentageFromCfi(rendition: EpubRendition, cfi: string): number {
if (!rendition.book?.locations) return 0;
const pct = rendition.book.locations.percentageFromCfi(cfi);
if (pct === null || pct === undefined) return 0;
return Math.round(pct * 100);
}
function cfiFromRendition(rendition: EpubRendition | null): string | null {
if (!rendition) return null;
const start = (rendition as EpubRendition & { location?: { start?: { cfi?: string } } }).location
?.start;
const cfi = start?.cfi;
return cfi && isEpubCfi(cfi) ? cfi : null;
}
export interface UseEpubReaderReturn {
epubUrl: string | null;
location: string | number;
chapterTitle: string;
progress: ReadingProgress | null;
isLoading: boolean;
error: string | null;
isBookmarkPeekActive: boolean;
readingAnchor: ReadingAnchor | null;
handleLocationChanged: (loc: string) => void;
handleGetRendition: (rendition: EpubRendition) => void;
navigateToHref: (href: string) => void;
beginBookmarkPeek: (targetCfi: string) => void;
resumeReadingAnchor: () => void;
goToNextPage: () => void;
goToPrevPage: () => void;
applySettings: (settings: ReadingSettings) => void;
renditionRef: React.RefObject<EpubRendition | null>;
epubOptions: {
flow: "paginated";
manager: "default";
spread: "none";
width: string;
height: string;
gap: number;
};
}
export function useEpubReader(
bookId: number,
marginWidth: number,
initialEpubLocation?: string,
): UseEpubReaderReturn {
const [epubUrl, setEpubUrl] = useState<string | null>(null);
const [location, setLocation] = useState<string | number>(0);
const [chapterTitle, setChapterTitle] = useState("");
const [progress, setProgress] = useState<ReadingProgress | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isBookmarkPeekActive, setIsBookmarkPeekActive] = useState(false);
const [readingAnchor, setReadingAnchor] = useState<ReadingAnchor | null>(null);
const renditionRef = useRef<EpubRendition | null>(null);
const settingsRef = useRef<ReadingSettings | null>(null);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingPersistRef = useRef<{ cfi: string; percentage: number } | null>(null);
const savedProgressPercentRef = useRef<number | null>(null);
const isBookmarkPeekRef = useRef(false);
const readingAnchorRef = useRef<ReadingAnchor | null>(null);
const epubOptions = {
flow: "paginated" as const,
manager: "default" as const,
spread: "none" as const,
width: "100%",
height: "100%",
gap: marginWidthToGap(marginWidth),
};
const clearPeekState = useCallback(() => {
isBookmarkPeekRef.current = false;
readingAnchorRef.current = null;
setIsBookmarkPeekActive(false);
setReadingAnchor(null);
}, []);
const setPeekState = useCallback((anchor: ReadingAnchor) => {
readingAnchorRef.current = anchor;
isBookmarkPeekRef.current = true;
setReadingAnchor(anchor);
setIsBookmarkPeekActive(true);
}, []);
const flushProgress = useCallback(
async (cfi: string, percentage: number) => {
if (isBookmarkPeekRef.current) return;
try {
const patch: Parameters<typeof updateReadingProgress>[1] = { epub_location: cfi };
if (percentage > 0) {
patch.percentage = percentage;
patch.current_position = percentage;
}
const updated = await updateReadingProgress(bookId, patch);
setProgress(updated);
if (percentage > 0) savedProgressPercentRef.current = percentage;
} catch {
/* silent — progress save is best-effort */
}
},
[bookId],
);
const scheduleProgress = useCallback(
(cfi: string, percentage: number) => {
if (isBookmarkPeekRef.current) return;
pendingPersistRef.current = { cfi, percentage };
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
const pending = pendingPersistRef.current;
if (pending) void flushProgress(pending.cfi, pending.percentage);
}, 800);
},
[flushProgress],
);
const captureAnchorFromCurrent = useCallback((): ReadingAnchor | null => {
const rendition = renditionRef.current;
let cfi: string | null = null;
if (typeof location === "string" && isEpubCfi(location)) {
cfi = location;
} else {
cfi = cfiFromRendition(rendition);
}
if (!cfi) return null;
const percentage = rendition ? percentageFromCfi(rendition, cfi) : 0;
return { cfi, percentage };
}, [location]);
const beginBookmarkPeek = useCallback(
(targetCfi: string) => {
if (!isEpubCfi(targetCfi)) return;
if (!isBookmarkPeekRef.current) {
let anchor = captureAnchorFromCurrent();
if (!anchor && progress?.epub_location && isEpubCfi(progress.epub_location)) {
const pct = progress.percentage ?? progress.current_position ?? 0;
anchor = { cfi: progress.epub_location, percentage: pct > 0 ? Math.round(pct) : 0 };
}
if (anchor) setPeekState(anchor);
}
setLocation(targetCfi);
},
[captureAnchorFromCurrent, progress, setPeekState],
);
const resumeReadingAnchor = useCallback(() => {
const anchor = readingAnchorRef.current;
clearPeekState();
if (!anchor?.cfi || !isEpubCfi(anchor.cfi)) return;
setLocation(anchor.cfi);
const rendition = renditionRef.current;
const pct = rendition ? percentageFromCfi(rendition, anchor.cfi) : anchor.percentage;
void flushProgress(anchor.cfi, pct > 0 ? pct : anchor.percentage);
}, [clearPeekState, flushProgress]);
useEffect(() => {
let blobUrl: string | null = null;
let cancelled = false;
savedProgressPercentRef.current = null;
pendingPersistRef.current = null;
clearPeekState();
async function loadEpub() {
setIsLoading(true);
setError(null);
try {
const [progressData, blob] = await Promise.all([
getReadingProgress(bookId).catch(() => null),
booksApi.getEpubFile(bookId),
]);
if (cancelled) return;
blobUrl = URL.createObjectURL(blob);
setEpubUrl(blobUrl);
const savedPct = progressData?.current_position ?? progressData?.percentage ?? 0;
savedProgressPercentRef.current =
typeof savedPct === "number" && savedPct > 0 ? savedPct : null;
const bookmarkPeekCfi =
initialEpubLocation && isEpubCfi(initialEpubLocation) ? initialEpubLocation : null;
const savedCfi =
progressData?.epub_location && isEpubCfi(progressData.epub_location)
? progressData.epub_location
: null;
if (bookmarkPeekCfi && savedCfi) {
const anchorPct =
typeof savedPct === "number" && savedPct > 0 ? Math.round(savedPct) : 0;
setPeekState({ cfi: savedCfi, percentage: anchorPct });
setProgress(progressData);
setLocation(bookmarkPeekCfi);
} else if (bookmarkPeekCfi) {
setLocation(bookmarkPeekCfi);
} else if (savedCfi) {
setProgress(progressData);
setLocation(savedCfi);
} else if (progressData) {
setProgress(progressData);
}
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load EPUB");
}
} finally {
if (!cancelled) setIsLoading(false);
}
}
void loadEpub();
return () => {
cancelled = true;
if (blobUrl) URL.revokeObjectURL(blobUrl);
if (debounceTimer.current) clearTimeout(debounceTimer.current);
if (!isBookmarkPeekRef.current) {
const pending = pendingPersistRef.current;
if (pending) void flushProgress(pending.cfi, pending.percentage);
}
clearPeekState();
};
}, [bookId, initialEpubLocation, flushProgress, clearPeekState, setPeekState]);
const handleLocationChanged = useCallback(
(loc: string) => {
setLocation(loc);
if (!isEpubCfi(loc)) return;
const rendition = renditionRef.current;
const percentage = rendition ? percentageFromCfi(rendition, loc) : 0;
if (rendition) {
void updateChapterTitleFromRendition(rendition).then((title) => {
if (title) setChapterTitle(title);
});
}
scheduleProgress(loc, percentage);
},
[scheduleProgress],
);
const navigateToHref = useCallback((href: string) => {
const rendition = renditionRef.current;
if (!rendition) {
setLocation(href);
return;
}
void rendition.book.ready.then(() => {
const target = resolveSpineHref(rendition.book, href);
setLocation(target);
return rendition.display(target);
});
}, []);
const syncProgressFromRendition = useCallback(
(attempt = 0) => {
if (isBookmarkPeekRef.current) return;
const rendition = renditionRef.current;
if (!rendition) return;
const start = (rendition as EpubRendition & { location?: { start?: { cfi?: string } } }).location
?.start;
const cfi = start?.cfi;
if (!cfi || !isEpubCfi(cfi)) return;
const percentage = percentageFromCfi(rendition, cfi);
void flushProgress(cfi, percentage);
if (percentage <= 0 && attempt < 2) {
setTimeout(() => syncProgressFromRendition(attempt + 1), attempt === 0 ? 600 : 1500);
}
},
[flushProgress],
);
const handleGetRendition = useCallback((rendition: EpubRendition) => {
const r = rendition as EpubRenditionWithManager;
renditionRef.current = rendition;
const resizeReader = () => {
const container = document.querySelector(".reader-epub-container");
if (container && "resize" in rendition) {
(rendition as EpubRendition & { resize: (w: number, h: number) => void }).resize(
container.clientWidth,
container.clientHeight,
);
}
};
r.hooks?.content?.register?.((contents: { document: Document }) => {
if (contents.document?.body) {
contents.document.body.style.userSelect = "text";
contents.document.body.style.webkitUserSelect = "text";
}
});
void r.book.ready
.then(() => r.book.locations.generate(1600))
.then(resizeReader)
.then(() => updateChapterTitleFromRendition(r))
.then((title) => {
if (title) setChapterTitle(title);
})
.then(() => syncProgressFromRendition());
window.addEventListener("resize", resizeReader);
if (settingsRef.current) {
applyRenditionSettings(rendition, settingsRef.current);
}
return () => window.removeEventListener("resize", resizeReader);
}, [syncProgressFromRendition]);
const applySettings = useCallback((settings: ReadingSettings) => {
settingsRef.current = settings;
if (renditionRef.current) {
applyRenditionSettings(renditionRef.current, settings);
}
}, []);
const goToNextPage = useCallback(() => {
renditionRef.current?.next();
}, []);
const goToPrevPage = useCallback(() => {
renditionRef.current?.prev();
}, []);
return {
epubUrl,
location,
chapterTitle,
progress,
isLoading,
error,
isBookmarkPeekActive,
readingAnchor,
handleLocationChanged,
handleGetRendition,
navigateToHref,
beginBookmarkPeek,
resumeReadingAnchor,
goToNextPage,
goToPrevPage,
applySettings,
epubOptions,
renditionRef,
};
}
+120
View File
@@ -0,0 +1,120 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { EpubRendition } from "../utils/epubRendition";
import { isEpubCfi } from "../utils/epubRendition";
export interface PendingSelection {
epubCfi: string;
locationText: string;
chapterIndex: number;
chapterTitle: string;
anchorTop: number;
anchorLeft: number;
}
type EpubContents = {
document: Document;
window?: Window;
cfiFromRange?: (range: Range) => string;
};
type EpubRenditionWithHooks = EpubRendition & {
on?: (event: string, handler: (cfiRange: unknown, contents: EpubContents) => void) => void;
off?: (event: string, handler: (cfiRange: unknown, contents: EpubContents) => void) => void;
location?: { start?: { index?: number; cfi?: string } };
hooks?: {
content?: {
register: (fn: (contents: EpubContents) => void) => void;
};
};
};
function extractSelection(
contents: EpubContents,
rendition: EpubRenditionWithHooks,
): PendingSelection | null {
const text = contents.window?.getSelection?.()?.toString?.()?.trim()
?? contents.document?.getSelection?.()?.toString?.()?.trim()
?? "";
if (!text) return null;
const sel = contents.document.getSelection();
if (!sel || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
let cfi = "";
try {
cfi = contents.cfiFromRange?.(range) ?? "";
} catch {
cfi = "";
}
if (!cfi || !isEpubCfi(cfi)) {
const locCfi = rendition.location?.start?.cfi;
if (locCfi && isEpubCfi(locCfi)) cfi = locCfi;
else return null;
}
const rect = range.getBoundingClientRect();
const chapterIndex = rendition.location?.start?.index ?? 0;
return {
epubCfi: cfi,
locationText: text.slice(0, 2000),
chapterIndex: typeof chapterIndex === "number" ? chapterIndex : 0,
chapterTitle: "",
anchorTop: rect.bottom + 8,
anchorLeft: Math.min(rect.left, window.innerWidth - 280),
};
}
export function useEpubSelection(
renditionRef: React.RefObject<EpubRendition | null>,
chapterTitle: string,
renditionVersion: number,
) {
const [pendingSelection, setPendingSelection] = useState<PendingSelection | null>(null);
const chapterTitleRef = useRef(chapterTitle);
chapterTitleRef.current = chapterTitle;
const clearSelection = useCallback(() => {
setPendingSelection(null);
}, []);
useEffect(() => {
const rendition = renditionRef.current as EpubRenditionWithHooks | null;
if (!rendition) return;
const applySelection = (partial: PendingSelection | null) => {
if (!partial) {
setPendingSelection(null);
return;
}
setPendingSelection({
...partial,
chapterTitle: chapterTitleRef.current,
});
};
const onSelected = (_cfiRange: unknown, contents: EpubContents) => {
applySelection(extractSelection(contents, rendition));
};
if (rendition.on) {
rendition.on("selected", onSelected);
}
rendition.hooks?.content?.register?.((contents: EpubContents) => {
const handler = () => {
window.setTimeout(() => {
applySelection(extractSelection(contents, rendition));
}, 10);
};
contents.document.addEventListener("mouseup", handler);
});
return () => {
rendition.off?.("selected", onSelected);
};
}, [renditionRef, renditionVersion]);
return { pendingSelection, clearSelection };
}
-99
View File
@@ -1,99 +0,0 @@
/**
* useReadingProgress — fetch and update reading progress for a book.
* Auto-saves when chapter or position changes.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { getReadingProgress, updateReadingProgress } from "../api/reader";
import type { ReadingProgress } from "../types/reader";
export interface UseReadingProgressReturn {
progress: ReadingProgress | null;
isLoading: boolean;
error: string | null;
saveProgress: (
chapter: number,
position: number,
percentage: number
) => Promise<void>;
/** Schedule a debounced save — fires at most once per 3 seconds */
debouncedSave: (
chapter: number,
position: number,
percentage: number
) => void;
}
export function useReadingProgress(
bookId: number
): UseReadingProgressReturn {
const [progress, setProgress] = useState<ReadingProgress | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Fetch progress on mount
useEffect(() => {
let cancelled = false;
setIsLoading(true);
getReadingProgress(bookId)
.then((data) => {
if (!cancelled) setProgress(data);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to load progress"
);
}
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [bookId]);
const saveProgress = useCallback(
async (chapter: number, position: number, percentage: number) => {
try {
const updated = await updateReadingProgress(bookId, {
current_chapter: chapter,
current_position: position,
percentage,
});
setProgress(updated);
setError(null);
} catch (err: unknown) {
setError(
err instanceof Error ? err.message : "Failed to save progress"
);
}
},
[bookId]
);
const debouncedSave = useCallback(
(chapter: number, position: number, percentage: number) => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
debounceTimer.current = setTimeout(() => {
saveProgress(chapter, position, percentage);
}, 3000);
},
[saveProgress]
);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, []);
return { progress, isLoading, error, saveProgress, debouncedSave };
}
+91 -27
View File
@@ -1,15 +1,25 @@
/**
* useReadingSettings — fetch and manage user reading preferences.
* Applies settings as CSS custom properties on the document root.
* Preview applies instantly to the reader; API saves are debounced for sliders.
*/
import { useCallback, useEffect, useState } from "react";
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,
@@ -24,22 +34,38 @@ const DEFAULT_SETTINGS: ReadingSettings = {
updated_at: "",
};
function applyCssVariables(settings: ReadingSettings): void {
const SAVE_DEBOUNCE_MS = 600;
export 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-font-family", settings.font_family);
root.style.setProperty("--reader-font-size", `${settings.font_size}px`);
root.style.setProperty("--reader-line-height", String(settings.line_height));
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>) => Promise<void>;
updateSettings: (
partial: Partial<ReadingSettings>,
persist?: SettingsPersistMode,
) => void;
flushSettings: () => Promise<void>;
}
export function useReadingSettings(): UseReadingSettingsReturn {
@@ -47,7 +73,45 @@ export function useReadingSettings(): UseReadingSettingsReturn {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch settings on mount
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);
@@ -55,16 +119,15 @@ export function useReadingSettings(): UseReadingSettingsReturn {
.then((data) => {
if (!cancelled) {
setSettings(data);
applyCssVariables(data);
applyReadingCssVariables(data);
}
})
.catch((err: unknown) => {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to load reading settings"
err instanceof Error ? err.message : "Failed to load reading settings",
);
// Apply defaults
applyCssVariables(DEFAULT_SETTINGS);
applyReadingCssVariables(DEFAULT_SETTINGS);
}
})
.finally(() => {
@@ -72,25 +135,26 @@ export function useReadingSettings(): UseReadingSettingsReturn {
});
return () => {
cancelled = true;
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
const updateSettings = useCallback(
async (partial: Partial<ReadingSettings>) => {
try {
const updated = await updateReadingSettings(partial);
setSettings(updated);
applyCssVariables(updated);
setError(null);
} catch (err: unknown) {
setError(
err instanceof Error ? err.message : "Failed to update reading settings"
);
throw err;
}
(partial: Partial<ReadingSettings>, persist: SettingsPersistMode = "debounced") => {
setSettings((prev) => {
const next = mergeSettings(prev, partial);
applyReadingCssVariables(next);
return next;
});
schedulePersist(partial, persist === "immediate");
},
[]
[schedulePersist],
);
return { settings, isLoading, error, updateSettings };
}
const flushSettings = useCallback(async () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
await runPersist();
}, [runPersist]);
return { settings, isLoading, error, updateSettings, flushSettings };
}
+66
View File
@@ -0,0 +1,66 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { ToastContainer } from "../components/ToastContainer";
export type ToastVariant = "success" | "error" | "warning";
export interface ToastItem {
id: string;
message: string;
variant: ToastVariant;
}
interface ShowToastOptions {
message: string;
variant?: ToastVariant;
durationMs?: number;
}
interface ToastContextValue {
showToast: (options: ShowToastOptions) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
const DEFAULT_DURATION_MS = 4000;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const dismissToast = useCallback((id: string) => {
const timer = timersRef.current.get(id);
if (timer) {
clearTimeout(timer);
timersRef.current.delete(id);
}
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const showToast = useCallback(
({ message, variant = "success", durationMs = DEFAULT_DURATION_MS }: ShowToastOptions) => {
const id = crypto.randomUUID();
setToasts((prev) => [...prev, { id, message, variant }]);
const timer = setTimeout(() => dismissToast(id), durationMs);
timersRef.current.set(id, timer);
},
[dismissToast],
);
const value = useMemo(() => ({ showToast }), [showToast]);
return (
<ToastContext.Provider value={value}>
{children}
<ToastContainer toasts={toasts} />
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error("useToast must be used within ToastProvider");
}
return ctx;
}