feat(api,web): support CBZ — scan, métadonnées, lecteur d'albums

- api: cbz utilitaire commun, scanner/métadonnées (+ tests), books, schéma et migrations
- web: CbzReader, ReaderPage/locators (+ tests), types et client API
- shared: types formats

Refs: #16
This commit is contained in:
Git Agent
2026-08-23 11:52:40 +02:00
parent 4b7d4d45ce
commit e5513d81eb
18 changed files with 366 additions and 15 deletions

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>
);
}