diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx
index de88794..aa2adfa 100644
--- a/apps/web/src/pages/ReaderPage.tsx
+++ b/apps/web/src/pages/ReaderPage.tsx
@@ -41,6 +41,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
}, [progress]);
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
+ const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
const savePdfPage = useCallback(
(nextPage: number, pages: number) => {
setPage(nextPage);
@@ -61,7 +62,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
return (
-
) : book.format === "pdf" ? (
-
+
) : book.format === "cbz" || book.format === "cbr" ? (
) : (
-
+
)}
);
diff --git a/apps/web/src/reader/EpubReader.tsx b/apps/web/src/reader/EpubReader.tsx
index 3b16cb1..2c089f3 100644
--- a/apps/web/src/reader/EpubReader.tsx
+++ b/apps/web/src/reader/EpubReader.tsx
@@ -1,43 +1,145 @@
import { useEffect, useRef, useState } from "react";
+import { ArrowLeft, ArrowRight } from "lucide-react";
+import { ReaderError, readerErrorMessage } from "./ReaderError";
-type FoliateModule = {
- EPUB?: unknown;
- default?: unknown;
+type FoliateLocation = {
+ cfi?: string;
+ fraction?: number;
+ current?: number;
+ total?: number;
};
-export function EpubReader({ url, locator, onLocatorChange }: { url: string; locator?: string; onLocatorChange: (locator: string, percent: number) => void }) {
+type FoliateView = HTMLElement & {
+ open(input: File | Blob | string): Promise;
+ close(): void;
+ goLeft(): Promise;
+ goRight(): Promise;
+ goTo(target: string): Promise;
+ next(): Promise;
+ lastLocation?: FoliateLocation;
+};
+
+export function epubFileName(url: string): string {
+ try {
+ const base = globalThis.location?.href ?? "http://readabook.local/";
+ const pathname = new URL(url, base).pathname;
+ const name = pathname.split("/").filter(Boolean).at(-1);
+ return name && name.includes(".") ? name : "book.epub";
+ } catch {
+ return "book.epub";
+ }
+}
+
+function locationPercent(location: FoliateLocation): number {
+ if (typeof location.fraction === "number") return Math.max(0, Math.min(100, location.fraction * 100));
+ if (typeof location.current === "number" && typeof location.total === "number" && location.total > 0) {
+ return Math.max(0, Math.min(100, (location.current / location.total) * 100));
+ }
+ return 1;
+}
+
+export function EpubReader({
+ url,
+ locator,
+ backHref,
+ onLocatorChange
+}: {
+ url: string;
+ locator?: string;
+ backHref: string;
+ onLocatorChange: (locator: string, percent: number) => void;
+}) {
const hostRef = useRef(null);
- const [frameKey, setFrameKey] = useState(0);
- const [status, setStatus] = useState("Ouverture EPUB");
+ const viewRef = useRef(null);
+ const locatorRef = useRef(locator);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState();
+ const [attempt, setAttempt] = useState(0);
+
+ useEffect(() => {
+ locatorRef.current = locator;
+ }, [locator]);
useEffect(() => {
let cancelled = false;
+ let view: FoliateView | null = null;
+
async function mount() {
try {
- const module = (await import("foliate-js/epub.js")) as FoliateModule;
+ setLoading(true);
+ setError(undefined);
+ await import("foliate-js/view.js");
if (cancelled || !hostRef.current) return;
- hostRef.current.dataset.engine = module.EPUB || module.default ? "foliate-js" : "fallback";
- setStatus("EPUB pret");
- } catch {
- setStatus("Apercu EPUB indisponible dans ce navigateur");
+
+ const response = await fetch(url, { credentials: "include" });
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
+ const blob = await response.blob();
+ if (cancelled || !hostRef.current) return;
+
+ view = document.createElement("foliate-view") as FoliateView;
+ view.classList.add("epub-view");
+ view.addEventListener("relocate", (event) => {
+ const location = (event as CustomEvent).detail;
+ if (location?.cfi) onLocatorChange(location.cfi, locationPercent(location));
+ });
+ hostRef.current.replaceChildren(view);
+ viewRef.current = view;
+
+ const file = new File([blob], epubFileName(url), { type: blob.type || "application/epub+zip" });
+ await view.open(file);
+ if (cancelled) return;
+ if (locatorRef.current) await view.goTo(locatorRef.current);
+ else await view.next();
+ setLoading(false);
+ } catch (mountError) {
+ if (!cancelled) {
+ setError(readerErrorMessage(mountError, "EPUB indisponible"));
+ setLoading(false);
+ }
}
}
- mount();
+
+ void mount();
return () => {
cancelled = true;
+ view?.close?.();
+ view?.remove();
+ if (viewRef.current === view) viewRef.current = null;
};
- }, [url]);
+ }, [attempt, onLocatorChange, url]);
+
+ if (error) {
+ return (
+
+ setAttempt((value) => value + 1)}
+ />
+
+ );
+ }
return (
-
-
-
-
{status}
-
onLocatorChange(locator ?? "epub:start", locator ? 35 : 1)}>
- Marquer la position
+
+ {loading && (
+
+ Ouverture EPUB
+
+ )}
+
+
+
void viewRef.current?.goLeft()} disabled={loading}>
+
+ Précédent
-
setFrameKey((value) => value + 1)}>
- Recharger
+ {loading ? "Chargement" : "Lecture intégrée"}
+ void viewRef.current?.goRight()} disabled={loading}>
+ Suivant
+
diff --git a/apps/web/src/reader/PdfReader.tsx b/apps/web/src/reader/PdfReader.tsx
index 4dae444..42cf30d 100644
--- a/apps/web/src/reader/PdfReader.tsx
+++ b/apps/web/src/reader/PdfReader.tsx
@@ -1,21 +1,36 @@
import { useEffect, useRef, useState } from "react";
import * as pdfjs from "pdfjs-dist";
-import workerUrl from "pdfjs-dist/build/pdf.worker.mjs?url";
+import { ReaderError, readerErrorMessage } from "./ReaderError";
+import { pdfWorkerSrc } from "./pdfWorker";
-pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
+pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
-export function PdfReader({ url, page, onPageCommit }: { url: string; page: number; onPageCommit: (page: number, pages: number) => void }) {
+export function PdfReader({
+ url,
+ page,
+ backHref,
+ onPageCommit
+}: {
+ url: string;
+ page: number;
+ backHref: string;
+ onPageCommit: (page: number, pages: number) => void;
+}) {
const canvasRef = useRef(null);
const [pages, setPages] = useState(1);
const [error, setError] = useState();
+ const [loading, setLoading] = useState(true);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
+ let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
+ let renderTask: pdfjs.RenderTask | undefined;
async function render() {
try {
+ setLoading(true);
setError(undefined);
- const loadingTask = pdfjs.getDocument({ url, withCredentials: true });
+ loadingTask = pdfjs.getDocument({ url, withCredentials: true });
const document = await loadingTask.promise;
if (cancelled) return;
setPages(document.numPages);
@@ -27,37 +42,53 @@ export function PdfReader({ url, page, onPageCommit }: { url: string; page: numb
canvas.height = viewport.height;
const context = canvas.getContext("2d");
if (!context) return;
- await pdfPage.render({ canvas, canvasContext: context, viewport }).promise;
+ renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
+ await renderTask.promise;
+ if (!cancelled) setLoading(false);
} catch (renderError) {
- setError(renderError instanceof Error ? renderError.message : "PDF indisponible");
+ if (!cancelled) {
+ setError(readerErrorMessage(renderError, "PDF indisponible"));
+ setLoading(false);
+ }
}
}
- render();
+ void render();
return () => {
cancelled = true;
+ renderTask?.cancel();
+ void loadingTask?.destroy();
};
}, [url, page, attempt]);
return (
{error ? (
-
- {error}
- setAttempt((value) => value + 1)}>
- Reessayer
-
-
+
setAttempt((value) => value + 1)}
+ />
) : (
-
+ <>
+ {loading && (
+
+ Ouverture PDF
+
+ )}
+
+ >
)}
- onPageCommit(Math.max(1, page - 1), pages)}>
- Precedent
+ onPageCommit(Math.max(1, page - 1), pages)} disabled={Boolean(error) || loading}>
+ Précédent
{page} / {pages}
- onPageCommit(Math.min(pages, page + 1), pages)}>
+ onPageCommit(Math.min(pages, page + 1), pages)} disabled={Boolean(error) || loading}>
Suivant
diff --git a/apps/web/src/reader/ReaderError.tsx b/apps/web/src/reader/ReaderError.tsx
new file mode 100644
index 0000000..7a184de
--- /dev/null
+++ b/apps/web/src/reader/ReaderError.tsx
@@ -0,0 +1,53 @@
+import { ArrowLeft, Download, RotateCcw } from "lucide-react";
+import { navigate } from "../router";
+
+export function readerErrorMessage(error: unknown, fallback: string): string {
+ if (error instanceof Error && error.message.trim()) return error.message;
+ if (typeof error === "string" && error.trim()) return error;
+ return fallback;
+}
+
+export function ReaderError({
+ title,
+ detail,
+ technicalDetail,
+ downloadUrl,
+ backHref,
+ onRetry
+}: {
+ title: string;
+ detail: string;
+ technicalDetail?: string;
+ downloadUrl: string;
+ backHref: string;
+ onRetry: () => void;
+}) {
+ return (
+
+
+
+
+
+ Réessayer
+
+
navigate(backHref)}>
+
+ Retour à la fiche
+
+
+
+ Télécharger
+
+
+ {technicalDetail && (
+
+ Détail technique
+ {technicalDetail}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/reader/pdfWorker.ts b/apps/web/src/reader/pdfWorker.ts
new file mode 100644
index 0000000..54cdf6d
--- /dev/null
+++ b/apps/web/src/reader/pdfWorker.ts
@@ -0,0 +1 @@
+export const pdfWorkerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
diff --git a/apps/web/src/reader/readerRuntime.test.ts b/apps/web/src/reader/readerRuntime.test.ts
new file mode 100644
index 0000000..87df2c5
--- /dev/null
+++ b/apps/web/src/reader/readerRuntime.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { epubFileName } from "./EpubReader";
+import { pdfWorkerSrc } from "./pdfWorker";
+import { readerErrorMessage } from "./ReaderError";
+
+describe("reader runtime helpers", () => {
+ it("extracts an EPUB file name from the file URL", () => {
+ expect(epubFileName("http://readabook.local/books/12/file?token=abc")).toBe("book.epub");
+ expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub");
+ });
+
+ it("keeps PDF.js worker source on the bundled module worker", () => {
+ expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs");
+ });
+
+ it("normalizes reader technical errors", () => {
+ expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed");
+ expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");
+ });
+});
diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css
index 7fc8fe0..ec74de5 100644
--- a/apps/web/src/styles/app.css
+++ b/apps/web/src/styles/app.css
@@ -676,8 +676,23 @@ select {
min-height: calc(100vh - 120px);
}
+.epub-host {
+ display: grid;
+ width: min(100%, 980px);
+ height: calc(100vh - 170px);
+ min-height: 460px;
+}
+
+.epub-view {
+ width: 100%;
+ height: 100%;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: #f7f0df;
+ color: #17110d;
+}
+
.pdf-reader canvas,
-.epub-reader iframe,
.cbz-reader img {
max-width: min(100%, 980px);
max-height: calc(100vh - 170px);
@@ -691,11 +706,6 @@ select {
object-fit: contain;
}
-.epub-reader iframe {
- width: min(100%, 980px);
- height: calc(100vh - 170px);
-}
-
.reader-fallback {
display: flex;
flex-wrap: wrap;
@@ -712,6 +722,46 @@ select {
gap: 12px;
}
+.reader-error {
+ display: grid;
+ gap: 14px;
+ width: min(100%, 620px);
+ padding: 18px;
+ border: 1px solid rgba(169, 72, 52, 0.72);
+ border-radius: var(--radius);
+ background: rgba(169, 72, 52, 0.14);
+}
+
+.reader-error p {
+ margin-bottom: 0;
+ color: var(--ink-muted);
+}
+
+.reader-error-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.reader-error a {
+ text-decoration: none;
+}
+
+.reader-error details {
+ color: var(--ink-muted);
+}
+
+.reader-error summary {
+ cursor: pointer;
+}
+
+.reader-error pre {
+ overflow: auto;
+ max-width: 100%;
+ margin: 10px 0 0;
+ white-space: pre-wrap;
+}
+
@keyframes spin {
to {
transform: rotate(360deg);
@@ -786,18 +836,21 @@ select {
.provider-row,
.provider-local,
.schedule-controls,
- .save-bar {
+ .save-bar,
+ .reader-error-actions {
grid-template-columns: 1fr;
}
.save-bar,
.save-bar div,
- .provider-actions {
+ .provider-actions,
+ .reader-error-actions {
justify-content: stretch;
}
.save-bar div,
- .provider-actions {
+ .provider-actions,
+ .reader-error-actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
index 43d0709..6a99ea7 100644
--- a/apps/web/src/vite-env.d.ts
+++ b/apps/web/src/vite-env.d.ts
@@ -5,3 +5,8 @@ declare module "foliate-js/epub.js" {
export default module;
export const EPUB: unknown;
}
+
+declare module "foliate-js/view.js" {
+ const module: unknown;
+ export default module;
+}