fix(web,api): UI fiche livre et lecteur — progression, locators EPUB/PDF, styles

- web: BookPage/ReaderPage enrichis, sauvegarde de progression lecteur
- web: locators EPUB/PDF normalisés (+ tests), EpubReader/PdfReader ajustés
- web: nginx et service worker ajustés
- api: progress — corrections contrôleur/service

Refs: #13
This commit is contained in:
Git Agent
2026-08-23 10:32:44 +02:00
parent e94524f119
commit 85b56ef3b2
12 changed files with 254 additions and 54 deletions

View File

@ -7,6 +7,7 @@ type FoliateModule = {
export function EpubReader({ url, locator, onLocatorChange }: { url: string; locator?: string; onLocatorChange: (locator: string, percent: number) => void }) {
const hostRef = useRef<HTMLDivElement>(null);
const [frameKey, setFrameKey] = useState(0);
const [status, setStatus] = useState("Ouverture EPUB");
useEffect(() => {
@ -17,7 +18,6 @@ export function EpubReader({ url, locator, onLocatorChange }: { url: string; loc
if (cancelled || !hostRef.current) return;
hostRef.current.dataset.engine = module.EPUB || module.default ? "foliate-js" : "fallback";
setStatus("EPUB pret");
onLocatorChange(locator ?? "epub:start", locator ? 35 : 1);
} catch {
setStatus("Apercu EPUB indisponible dans ce navigateur");
}
@ -26,12 +26,20 @@ export function EpubReader({ url, locator, onLocatorChange }: { url: string; loc
return () => {
cancelled = true;
};
}, [locator, onLocatorChange, url]);
}, [url]);
return (
<div className="epub-reader" ref={hostRef}>
<iframe title="EPUB" src={url} />
<div className="reader-fallback">{status}</div>
<iframe key={frameKey} title="EPUB" src={url} />
<div className="reader-fallback">
<span>{status}</span>
<button className="ghost-button" onClick={() => onLocatorChange(locator ?? "epub:start", locator ? 35 : 1)}>
Marquer la position
</button>
<button className="ghost-button" onClick={() => setFrameKey((value) => value + 1)}>
Recharger
</button>
</div>
</div>
);
}

View File

@ -4,15 +4,17 @@ import workerUrl from "pdfjs-dist/build/pdf.worker.mjs?url";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
export function PdfReader({ url, page, onPageChange }: { url: string; page: number; onPageChange: (page: number, pages: number) => void }) {
export function PdfReader({ url, page, onPageCommit }: { url: string; page: number; onPageCommit: (page: number, pages: number) => void }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [pages, setPages] = useState(1);
const [error, setError] = useState<string>();
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
async function render() {
try {
setError(undefined);
const loadingTask = pdfjs.getDocument({ url, withCredentials: true });
const document = await loadingTask.promise;
if (cancelled) return;
@ -26,7 +28,6 @@ export function PdfReader({ url, page, onPageChange }: { url: string; page: numb
const context = canvas.getContext("2d");
if (!context) return;
await pdfPage.render({ canvas, canvasContext: context, viewport }).promise;
onPageChange(Math.max(1, Math.min(page, document.numPages)), document.numPages);
} catch (renderError) {
setError(renderError instanceof Error ? renderError.message : "PDF indisponible");
}
@ -35,19 +36,28 @@ export function PdfReader({ url, page, onPageChange }: { url: string; page: numb
return () => {
cancelled = true;
};
}, [url, page, onPageChange]);
}, [url, page, attempt]);
return (
<div className="pdf-reader">
{error ? <div className="reader-fallback">{error}</div> : <canvas ref={canvasRef} />}
{error ? (
<div className="reader-fallback">
<span>{error}</span>
<button className="ghost-button" onClick={() => setAttempt((value) => value + 1)}>
Reessayer
</button>
</div>
) : (
<canvas ref={canvasRef} />
)}
<div className="reader-stepper">
<button className="ghost-button" onClick={() => onPageChange(Math.max(1, page - 1), pages)}>
<button className="ghost-button" onClick={() => onPageCommit(Math.max(1, page - 1), pages)}>
Precedent
</button>
<span>
{page} / {pages}
</span>
<button className="ghost-button" onClick={() => onPageChange(Math.min(pages, page + 1), pages)}>
<button className="ghost-button" onClick={() => onPageCommit(Math.min(pages, page + 1), pages)}>
Suivant
</button>
</div>

View File

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { parsePdfPageLocator, pdfPagePercent } from "./locators";
describe("reader locators", () => {
it("parses valid PDF page locators", () => {
expect(parsePdfPageLocator("pdf:page:12")).toBe(12);
});
it("ignores invalid PDF locators", () => {
expect(parsePdfPageLocator("epub:start")).toBeNull();
expect(parsePdfPageLocator("pdf:page:0")).toBeNull();
});
it("bounds PDF page percentages", () => {
expect(pdfPagePercent(2, 4)).toBe(50);
expect(pdfPagePercent(8, 4)).toBe(100);
expect(pdfPagePercent(1, 0)).toBe(0);
});
});

View File

@ -0,0 +1,10 @@
export function parsePdfPageLocator(locator?: string | null): number | null {
if (!locator?.startsWith("pdf:page:")) return null;
const value = Number(locator.split(":").at(-1));
return Number.isInteger(value) && value > 0 ? value : null;
}
export function pdfPagePercent(page: number, pages: number): number {
if (!Number.isFinite(page) || !Number.isFinite(pages) || pages < 1) return 0;
return Math.max(0, Math.min(100, Math.round((page / pages) * 100)));
}

View File

@ -1,21 +1,42 @@
import { useCallback, useEffect, useState } from "react";
import type { ProgressDto } from "@readabook/shared";
import { api } from "../api/client";
import { api, getApiFallback } from "../api/client";
export function useReaderProgress(bookId: number) {
const [progress, setProgress] = useState<ProgressDto | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
useEffect(() => {
api.progress(bookId).then(setProgress);
let alive = true;
api
.progress(bookId)
.then((next) => {
if (alive) setProgress(next);
})
.catch(() => {
if (alive) setError("Progression indisponible.");
});
return () => {
alive = false;
};
}, [bookId]);
const save = useCallback(
async (locator: string, percent: number) => {
setSaving(true);
setError(undefined);
try {
const next = await api.saveProgress(bookId, { locator, percent });
setProgress(next);
} catch (saveError) {
const fallback = getApiFallback<ProgressDto>(saveError);
if (fallback) {
setProgress(fallback);
setError("Progression conservee localement, synchronisation a retenter.");
} else {
setError("Sauvegarde de progression impossible.");
}
} finally {
setSaving(false);
}
@ -23,5 +44,5 @@ export function useReaderProgress(bookId: number) {
[bookId]
);
return { progress, saving, save };
return { progress, saving, error, save };
}