- api: cbr utilitaire (extraction RAR), scanner/métadonnées (+ tests), books, schéma - web: ReaderPage/locators (+ tests), mockData, types partagés - deps: pnpm-lock Refs: #17
99 lines
3.7 KiB
TypeScript
99 lines
3.7 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { ArrowLeft, RotateCcw, Save } from "lucide-react";
|
|
import type { BookDto } from "@readabook/shared";
|
|
import { api, getApiFallback } from "../api/client";
|
|
import { ErrorRibbon, Meter } from "../components/ui";
|
|
import { navigate } from "../router";
|
|
import { CbzReader } from "../reader/CbzReader";
|
|
import { EpubReader } from "../reader/EpubReader";
|
|
import { parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
|
import { PdfReader } from "../reader/PdfReader";
|
|
import { useReaderProgress } from "../reader/useReaderProgress";
|
|
|
|
export function ReaderPage({ bookId }: { bookId: number }) {
|
|
const [book, setBook] = useState<BookDto | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string>();
|
|
const [page, setPage] = useState(1);
|
|
const { progress, saving, error: progressError, save } = useReaderProgress(bookId);
|
|
|
|
async function loadBook() {
|
|
setLoading(true);
|
|
setError(undefined);
|
|
try {
|
|
setBook(await api.book(bookId));
|
|
} catch (loadError) {
|
|
const fallback = getApiFallback<BookDto>(loadError);
|
|
setBook(fallback ?? null);
|
|
setError(fallback ? "Lecteur ouvert en mode secours." : "Ouvrage indisponible.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadBook();
|
|
}, [bookId]);
|
|
|
|
useEffect(() => {
|
|
const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator);
|
|
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
|
}, [progress]);
|
|
|
|
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
|
|
const savePdfPage = useCallback(
|
|
(nextPage: number, pages: number) => {
|
|
setPage(nextPage);
|
|
void save(`pdf:page:${nextPage}`, pdfPagePercent(nextPage, pages));
|
|
},
|
|
[save]
|
|
);
|
|
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
|
|
const saveComicPage = useCallback(
|
|
(nextPage: number, pages: number) => {
|
|
setPage(nextPage);
|
|
const prefix = book?.format === "cbr" ? "cbr" : "cbz";
|
|
void save(`${prefix}:page:${nextPage}`, pdfPagePercent(nextPage, pages));
|
|
},
|
|
[book?.format, save]
|
|
);
|
|
|
|
return (
|
|
<div className="reader-page">
|
|
<header className="reader-topbar">
|
|
<button className="ghost-button" onClick={() => navigate(book ? `/book/${book.id}` : "/home")}>
|
|
<ArrowLeft size={17} />
|
|
Fiche
|
|
</button>
|
|
<div>
|
|
<strong>{book?.title ?? "Ouverture du lecteur"}</strong>
|
|
<span>{loading ? "Chargement" : saving ? "Sauvegarde" : "Progression synchronisee"}</span>
|
|
</div>
|
|
{error ? (
|
|
<button className="ghost-button icon-only" onClick={() => void loadBook()} aria-label="Reessayer">
|
|
<RotateCcw size={18} />
|
|
</button>
|
|
) : (
|
|
<Save size={18} />
|
|
)}
|
|
</header>
|
|
<ErrorRibbon message={error ?? progressError} />
|
|
<Meter value={progress?.percent ?? 0} />
|
|
{!book ? (
|
|
<div className="reader-fallback">
|
|
<span>{error ?? "Chargement du livre."}</span>
|
|
<button className="ghost-button" onClick={() => void loadBook()}>
|
|
Reessayer
|
|
</button>
|
|
</div>
|
|
) : book.format === "pdf" ? (
|
|
<PdfReader url={fileUrl} page={page} onPageCommit={savePdfPage} />
|
|
) : book.format === "cbz" || book.format === "cbr" ? (
|
|
<CbzReader bookId={book.id} page={page} onPageCommit={saveComicPage} />
|
|
) : (
|
|
<EpubReader url={fileUrl} locator={progress?.locator} onLocatorChange={saveEpubLocator} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|