fix(web,api): lecteur — worker pdf.js dédié, shell commun et préférences par livre

Régression worker PDF : le worker pdf.js est désormais instancié une
seule fois via un port dédié (?worker&url) et reconfiguré à chaque
montage, au lieu d'un workerSrc recalculé qui cassait le rendu.

- ReaderShell : chrome commun aux lecteurs (toolbar, zones de tap,
  statut) et contrat ReaderControls pour EPUB/PDF/CBZ
- préférences de lecture par livre (mode horizontal/vertical, fit) :
  table reader_preferences + migrations idempotentes, module API,
  DTO partagés, client web avec fallback localStorage hors-ligne

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-23 18:06:22 +02:00
parent 8024cab11c
commit 5de46a6f6d
19 changed files with 790 additions and 89 deletions

View File

@ -1,21 +1,31 @@
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 { ReaderShell, type ReaderControls } from "../reader/ReaderShell";
import { useReaderPreferences } from "../reader/useReaderPreferences";
import { useReaderProgress } from "../reader/useReaderProgress";
const idleControls: ReaderControls = {
canPrevious: false,
canNext: false,
positionLabel: "Chargement",
onPrevious: () => undefined,
onNext: () => undefined
};
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 [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
const [controlsVisible, setControlsVisible] = useState(true);
const { progress, saving, error: progressError, save } = useReaderProgress(bookId);
const { preferences, setMode, error: preferencesError } = useReaderPreferences(bookId);
async function loadBook() {
setLoading(true);
@ -35,6 +45,11 @@ export function ReaderPage({ bookId }: { bookId: number }) {
void loadBook();
}, [bookId]);
useEffect(() => {
setReaderControls(idleControls);
setControlsVisible(true);
}, [bookId]);
useEffect(() => {
const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator);
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
@ -59,27 +74,22 @@ export function ReaderPage({ bookId }: { bookId: number }) {
[book?.format, save]
);
const readerError = error ?? progressError ?? preferencesError;
return (
<div className="reader-page">
<header className="reader-topbar">
<button className="ghost-button" onClick={() => navigate(backHref)}>
<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} />
<ReaderShell
title={book?.title ?? "Ouverture du lecteur"}
status={loading ? "Chargement" : saving ? "Sauvegarde" : "Progression synchronisee"}
backHref={backHref}
progress={progress?.percent ?? 0}
error={readerError}
onRetry={error ? () => void loadBook() : undefined}
mode={preferences.mode}
onModeChange={setMode}
controls={readerControls}
controlsVisible={controlsVisible}
onToggleControls={() => setControlsVisible((visible) => !visible)}
>
{!book ? (
<div className="reader-fallback">
<span>{error ?? "Chargement du livre."}</span>
@ -88,12 +98,32 @@ export function ReaderPage({ bookId }: { bookId: number }) {
</button>
</div>
) : book.format === "pdf" ? (
<PdfReader url={fileUrl} page={page} backHref={backHref} onPageCommit={savePdfPage} />
<PdfReader
url={fileUrl}
page={page}
backHref={backHref}
mode={preferences.mode}
onPageCommit={savePdfPage}
onControlsChange={setReaderControls}
/>
) : book.format === "cbz" || book.format === "cbr" ? (
<CbzReader bookId={book.id} page={page} onPageCommit={saveComicPage} />
<CbzReader
bookId={book.id}
page={page}
mode={preferences.mode}
onPageCommit={saveComicPage}
onControlsChange={setReaderControls}
/>
) : (
<EpubReader url={fileUrl} locator={progress?.locator} backHref={backHref} onLocatorChange={saveEpubLocator} />
<EpubReader
url={fileUrl}
locator={progress?.locator}
backHref={backHref}
mode={preferences.mode}
onLocatorChange={saveEpubLocator}
onControlsChange={setReaderControls}
/>
)}
</div>
</ReaderShell>
);
}