merge: feat/16-cbz-support dans develop (support CBZ)

This commit is contained in:
Git Agent
2026-08-23 11:54:13 +02:00
18 changed files with 366 additions and 15 deletions

View File

@ -13,7 +13,7 @@ import type {
UserDto
} from "@readabook/shared";
import { mockBooks, mockContinue, mockJobs, mockLibraries, mockProgress, mockUser } from "./mockData";
import type { ContinueItem, Session } from "./types";
import type { CbzPagesDto, ContinueItem, Session } from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
@ -140,6 +140,12 @@ export const api = {
bookCoverUrl(id: number): string {
return `${API_BASE}/books/${id}/cover`;
},
async cbzPages(id: number): Promise<CbzPagesDto> {
return request<CbzPagesDto>(`/books/${id}/pages`);
},
cbzPageUrl(id: number, page: number): string {
return `${API_BASE}/books/${id}/pages/${page}`;
},
async progress(bookId: number): Promise<ProgressDto | null> {
try {
return await request<ProgressDto>(`/progress/${bookId}`, {

View File

@ -52,12 +52,31 @@ export const mockBooks: BookDto[] = [
fileMtime: now,
createdAt: now,
updatedAt: now
},
{
id: 3,
libraryId: 2,
title: "Les vitrines de verre",
author: "A. Muze",
description: "Un recit graphique indexe comme archive CBZ.",
isbn: null,
language: "fr",
publisher: "ReadaBook",
publishedDate: "1934",
format: "cbz",
filePath: "/library/cbz/vitrines.cbz",
coverPath: null,
fileSize: 12600000,
fileMtime: now,
createdAt: now,
updatedAt: now
}
];
export const mockProgress: ProgressDto[] = [
{ bookId: 1, locator: "mock:chapter-3", percent: 42, updatedAt: now },
{ bookId: 2, locator: "mock:page-12", percent: 18, updatedAt: now }
{ bookId: 2, locator: "pdf:page:12", percent: 18, updatedAt: now },
{ bookId: 3, locator: "cbz:page:4", percent: 40, updatedAt: now }
];
export const mockContinue: ContinueItem[] = mockProgress.map((progress) => ({

View File

@ -21,3 +21,9 @@ export type DashboardData = {
libraries: LibraryDto[];
jobs: JobDto[];
};
export type CbzPagesDto = {
bookId: number;
pageCount: number;
pages: Array<{ page: number; name: string }>;
};

View File

@ -28,7 +28,7 @@ export function ErrorRibbon({ message }: { message?: string }) {
return <div className="error-ribbon">{message}</div>;
}
export function FormatPill({ format }: { format: "epub" | "pdf" }) {
export function FormatPill({ format }: { format: "epub" | "pdf" | "cbz" }) {
return <span className={`format-pill format-${format}`}>{format.toUpperCase()}</span>;
}

View File

@ -4,8 +4,9 @@ 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 { parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
import { parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
import { PdfReader } from "../reader/PdfReader";
import { useReaderProgress } from "../reader/useReaderProgress";
@ -35,7 +36,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
}, [bookId]);
useEffect(() => {
const nextPage = parsePdfPageLocator(progress?.locator);
const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator);
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
}, [progress]);
@ -48,6 +49,13 @@ export function ReaderPage({ bookId }: { bookId: number }) {
[save]
);
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
const saveCbzPage = useCallback(
(nextPage: number, pages: number) => {
setPage(nextPage);
void save(`cbz:page:${nextPage}`, pdfPagePercent(nextPage, pages));
},
[save]
);
return (
<div className="reader-page">
@ -79,6 +87,8 @@ export function ReaderPage({ bookId }: { bookId: number }) {
</div>
) : book.format === "pdf" ? (
<PdfReader url={fileUrl} page={page} onPageCommit={savePdfPage} />
) : book.format === "cbz" ? (
<CbzReader bookId={book.id} page={page} onPageCommit={saveCbzPage} />
) : (
<EpubReader url={fileUrl} locator={progress?.locator} onLocatorChange={saveEpubLocator} />
)}

View File

@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import type { CbzPagesDto } from "../api/types";
export function CbzReader({
bookId,
page,
onPageCommit
}: {
bookId: number;
page: number;
onPageCommit: (page: number, pages: number) => void;
}) {
const [pages, setPages] = useState<CbzPagesDto | null>(null);
const [error, setError] = useState<string>();
const [imageError, setImageError] = useState(false);
useEffect(() => {
let alive = true;
setError(undefined);
api
.cbzPages(bookId)
.then((nextPages) => {
if (!alive) return;
setPages(nextPages);
if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount);
})
.catch(() => {
if (alive) setError("Archive CBZ indisponible.");
});
return () => {
alive = false;
};
}, [bookId]);
const pageCount = pages?.pageCount ?? 1;
const currentPage = Math.max(1, Math.min(page, pageCount));
const currentName = pages?.pages.find((item) => item.page === currentPage)?.name;
function go(nextPage: number) {
setImageError(false);
onPageCommit(Math.max(1, Math.min(nextPage, pageCount)), pageCount);
}
return (
<div className="cbz-reader">
{error || imageError ? (
<div className="reader-fallback">
<span>{error ?? "Page CBZ indisponible."}</span>
<button className="ghost-button" onClick={() => go(currentPage)}>
Reessayer
</button>
</div>
) : (
<img src={api.cbzPageUrl(bookId, currentPage)} alt={currentName ?? `Page ${currentPage}`} onError={() => setImageError(true)} />
)}
<div className="reader-stepper">
<button className="ghost-button" onClick={() => go(currentPage - 1)}>
Precedent
</button>
<span>
{currentPage} / {pageCount}
</span>
<button className="ghost-button" onClick={() => go(currentPage + 1)}>
Suivant
</button>
</div>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parsePdfPageLocator, pdfPagePercent } from "./locators";
import { parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "./locators";
describe("reader locators", () => {
it("parses valid PDF page locators", () => {
@ -11,6 +11,11 @@ describe("reader locators", () => {
expect(parsePdfPageLocator("pdf:page:0")).toBeNull();
});
it("parses CBZ page locators", () => {
expect(parseCbzPageLocator("cbz:page:7")).toBe(7);
expect(parseCbzPageLocator("pdf:page:7")).toBeNull();
});
it("bounds PDF page percentages", () => {
expect(pdfPagePercent(2, 4)).toBe(50);
expect(pdfPagePercent(8, 4)).toBe(100);

View File

@ -1,5 +1,14 @@
export function parsePdfPageLocator(locator?: string | null): number | null {
if (!locator?.startsWith("pdf:page:")) return null;
return parsePageSuffix(locator);
}
export function parseCbzPageLocator(locator?: string | null): number | null {
if (!locator?.startsWith("cbz:page:")) return null;
return parsePageSuffix(locator);
}
function parsePageSuffix(locator: string): number | null {
const value = Number(locator.split(":").at(-1));
return Number.isInteger(value) && value > 0 ? value : null;
}

View File

@ -258,6 +258,11 @@ h2 {
color: var(--ink);
}
.format-cbz {
background: var(--violet-glass);
color: var(--ink);
}
.continue-grid,
.library-list,
.job-list {
@ -515,7 +520,8 @@ input {
}
.pdf-reader,
.epub-reader {
.epub-reader,
.cbz-reader {
display: grid;
place-items: center;
gap: 10px;
@ -523,7 +529,8 @@ input {
}
.pdf-reader canvas,
.epub-reader iframe {
.epub-reader iframe,
.cbz-reader img {
max-width: min(100%, 980px);
max-height: calc(100vh - 170px);
border: 1px solid var(--line);
@ -531,6 +538,11 @@ input {
background: #f7f0df;
}
.cbz-reader img {
width: auto;
object-fit: contain;
}
.epub-reader iframe {
width: min(100%, 980px);
height: calc(100vh - 170px);