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:
@ -26,6 +26,19 @@ export class BooksController {
|
||||
return this.books.get(Number(id));
|
||||
}
|
||||
|
||||
@Get(":id/pages")
|
||||
pages(@Param("id") id: string) {
|
||||
return this.books.listCbzPages(Number(id));
|
||||
}
|
||||
|
||||
@Get(":id/pages/:page")
|
||||
page(@Param("id") id: string, @Param("page") page: string, @Res() reply: FastifyReply) {
|
||||
const result = this.books.readCbzPage(Number(id), Number(page));
|
||||
reply.header("Content-Type", result.contentType);
|
||||
reply.header("Cache-Control", "private, max-age=3600");
|
||||
return reply.send(result.data);
|
||||
}
|
||||
|
||||
@Get(":id/file")
|
||||
file(@Param("id") id: string, @Res() reply: FastifyReply) {
|
||||
const { book, stream } = this.books.streamFile(Number(id));
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { extname } from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { BookQueryDto } from "@readabook/shared";
|
||||
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { books } from "../database/schema.js";
|
||||
|
||||
@ -66,9 +68,49 @@ export class BooksService {
|
||||
return { book, stream: createReadStream(book.coverPath), coverPath: book.coverPath };
|
||||
}
|
||||
|
||||
listCbzPages(id: number) {
|
||||
const book = this.get(id);
|
||||
this.assertCbzBook(book);
|
||||
const pages = listCbzImageEntries(book.filePath);
|
||||
return {
|
||||
bookId: book.id,
|
||||
pageCount: pages.length,
|
||||
pages: pages.map((page, index) => ({ page: index + 1, name: page.name }))
|
||||
};
|
||||
}
|
||||
|
||||
readCbzPage(id: number, page: number) {
|
||||
const book = this.get(id);
|
||||
this.assertCbzBook(book);
|
||||
try {
|
||||
const result = readCbzPage(book.filePath, page);
|
||||
return { book, page, contentType: lookupMime(result.entryName), data: result.data };
|
||||
} catch (error) {
|
||||
throw new NotFoundException(error instanceof Error ? error.message : "CBZ page not found");
|
||||
}
|
||||
}
|
||||
|
||||
count() {
|
||||
return this.database.db.select({ count: sql<number>`count(*)` }).from(books).get()?.count ?? 0;
|
||||
}
|
||||
|
||||
private assertCbzBook(book: typeof books.$inferSelect): void {
|
||||
if (book.format !== "cbz") {
|
||||
throw new BadRequestException("Book is not a CBZ archive");
|
||||
}
|
||||
if (!existsSync(book.filePath)) {
|
||||
throw new NotFoundException("Book file not found on disk");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function lookupMime(entryName: string): string {
|
||||
const extension = extname(entryName).toLowerCase();
|
||||
if (extension === ".png") return "image/png";
|
||||
if (extension === ".webp") return "image/webp";
|
||||
if (extension === ".gif") return "image/gif";
|
||||
if (extension === ".avif") return "image/avif";
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
function mapBookRow(row: Record<string, unknown>) {
|
||||
|
||||
46
apps/api/src/common/cbz.ts
Normal file
46
apps/api/src/common/cbz.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { extname } from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
|
||||
export type CbzPageEntry = {
|
||||
entryName: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]);
|
||||
const MAX_CBZ_ENTRIES = 20000;
|
||||
|
||||
export function listCbzImageEntries(filePath: string): CbzPageEntry[] {
|
||||
const zip = new AdmZip(filePath);
|
||||
const entries = zip.getEntries();
|
||||
if (entries.length > MAX_CBZ_ENTRIES) {
|
||||
throw new Error("CBZ archive has too many entries");
|
||||
}
|
||||
|
||||
const images = entries
|
||||
.filter((entry) => !entry.isDirectory && IMAGE_EXTENSIONS.has(extname(entry.entryName).toLowerCase()))
|
||||
.map((entry) => ({ entryName: entry.entryName, name: entry.name }))
|
||||
.sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true, sensitivity: "base" }));
|
||||
|
||||
if (!images.length) {
|
||||
throw new Error("CBZ archive does not contain readable image pages");
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
export function readCbzPage(filePath: string, pageNumber: number): { entryName: string; data: Buffer } {
|
||||
if (!Number.isInteger(pageNumber) || pageNumber < 1) {
|
||||
throw new Error("CBZ page number must be a positive integer");
|
||||
}
|
||||
const zip = new AdmZip(filePath);
|
||||
const pages = listCbzImageEntries(filePath);
|
||||
const page = pages[pageNumber - 1];
|
||||
if (!page) {
|
||||
throw new Error("CBZ page not found");
|
||||
}
|
||||
const entry = zip.getEntry(page.entryName);
|
||||
if (!entry) {
|
||||
throw new Error("CBZ page not found");
|
||||
}
|
||||
return { entryName: entry.entryName, data: entry.getData() };
|
||||
}
|
||||
@ -59,7 +59,7 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
language TEXT,
|
||||
publisher TEXT,
|
||||
published_date TEXT,
|
||||
format TEXT NOT NULL CHECK (format IN ('epub','pdf')),
|
||||
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz')),
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
cover_path TEXT,
|
||||
file_size INTEGER NOT NULL,
|
||||
@ -119,6 +119,78 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
VALUES (new.id, new.title, new.author, new.description, new.isbn);
|
||||
END;
|
||||
`);
|
||||
this.ensureBooksSupportsCbz();
|
||||
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
|
||||
}
|
||||
|
||||
private ensureBooksSupportsCbz(): void {
|
||||
const table = this.sqlite
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'books'")
|
||||
.get() as { sql?: string } | undefined;
|
||||
if (!table?.sql || table.sql.includes("'cbz'")) return;
|
||||
|
||||
this.sqlite.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
PRAGMA legacy_alter_table = ON;
|
||||
|
||||
DROP TRIGGER IF EXISTS books_ai;
|
||||
DROP TRIGGER IF EXISTS books_ad;
|
||||
DROP TRIGGER IF EXISTS books_au;
|
||||
|
||||
BEGIN;
|
||||
ALTER TABLE books RENAME TO books_legacy_format;
|
||||
CREATE TABLE books (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT,
|
||||
description TEXT,
|
||||
isbn TEXT,
|
||||
language TEXT,
|
||||
publisher TEXT,
|
||||
published_date TEXT,
|
||||
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz')),
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
cover_path TEXT,
|
||||
file_size INTEGER NOT NULL,
|
||||
file_mtime TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO books (
|
||||
id, library_id, title, author, description, isbn, language, publisher, published_date,
|
||||
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, library_id, title, author, description, isbn, language, publisher, published_date,
|
||||
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
||||
FROM books_legacy_format;
|
||||
DROP TABLE books_legacy_format;
|
||||
COMMIT;
|
||||
|
||||
PRAGMA legacy_alter_table = OFF;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS books_file_path_unique ON books(file_path);
|
||||
CREATE INDEX IF NOT EXISTS books_library_idx ON books(library_id);
|
||||
CREATE INDEX IF NOT EXISTS books_title_idx ON books(title);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS books_ai AFTER INSERT ON books BEGIN
|
||||
INSERT INTO book_fts(rowid, title, author, description, isbn)
|
||||
VALUES (new.id, new.title, new.author, new.description, new.isbn);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS books_ad AFTER DELETE ON books BEGIN
|
||||
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
|
||||
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS books_au AFTER UPDATE ON books BEGIN
|
||||
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
|
||||
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
|
||||
INSERT INTO book_fts(rowid, title, author, description, isbn)
|
||||
VALUES (new.id, new.title, new.author, new.description, new.isbn);
|
||||
END;
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ export const books = sqliteTable(
|
||||
language: text("language"),
|
||||
publisher: text("publisher"),
|
||||
publishedDate: text("published_date"),
|
||||
format: text("format", { enum: ["epub", "pdf"] }).notNull(),
|
||||
format: text("format", { enum: ["epub", "pdf", "cbz"] }).notNull(),
|
||||
filePath: text("file_path").notNull(),
|
||||
coverPath: text("cover_path"),
|
||||
fileSize: integer("file_size").notNull(),
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { listCbzImageEntries } from "../common/cbz.js";
|
||||
import { extractMetadata } from "./metadata.js";
|
||||
|
||||
describe("pdf metadata extraction", () => {
|
||||
@ -16,3 +18,21 @@ describe("pdf metadata extraction", () => {
|
||||
expect(metadata.author).toBe("Ada");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cbz metadata extraction", () => {
|
||||
it("uses the file name as title and first image as cover", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
|
||||
const file = join(dir, "Comic One.cbz");
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("002.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
||||
zip.addFile("001.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
||||
zip.writeZip(file);
|
||||
|
||||
const metadata = extractMetadata(file, dir);
|
||||
const pages = listCbzImageEntries(file);
|
||||
|
||||
expect(metadata.title).toBe("Comic One");
|
||||
expect(metadata.coverPath).toMatch(/covers\/[a-f0-9]+\.jpg$/);
|
||||
expect(pages.map((page) => page.name)).toEqual(["001.jpg", "002.jpg"]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,6 +3,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
import { listCbzImageEntries } from "../common/cbz.js";
|
||||
|
||||
export type BookMetadata = {
|
||||
title: string;
|
||||
@ -26,6 +27,9 @@ export function extractMetadata(filePath: string, storageDir: string): BookMetad
|
||||
if (extension === ".epub") {
|
||||
return extractEpubMetadata(filePath, storageDir);
|
||||
}
|
||||
if (extension === ".cbz") {
|
||||
return extractCbzMetadata(filePath, storageDir);
|
||||
}
|
||||
return extractPdfMetadata(filePath);
|
||||
}
|
||||
|
||||
@ -78,6 +82,16 @@ function extractPdfMetadata(filePath: string): BookMetadata {
|
||||
};
|
||||
}
|
||||
|
||||
function extractCbzMetadata(filePath: string, storageDir: string): BookMetadata {
|
||||
const zip = new AdmZip(filePath);
|
||||
const firstPage = listCbzImageEntries(filePath)[0];
|
||||
const coverPath = extractCover(zip, firstPage.entryName, filePath, storageDir);
|
||||
return {
|
||||
...fallbackMetadata(filePath),
|
||||
coverPath
|
||||
};
|
||||
}
|
||||
|
||||
function fallbackMetadata(filePath: string): BookMetadata {
|
||||
return {
|
||||
title: basename(filePath, extname(filePath)),
|
||||
|
||||
@ -50,7 +50,7 @@ export class ScannerService {
|
||||
}
|
||||
|
||||
const now = this.database.now();
|
||||
const format: "epub" | "pdf" = extname(filePath).toLowerCase() === ".epub" ? "epub" : "pdf";
|
||||
const format = bookFormatFromPath(filePath);
|
||||
const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get();
|
||||
const values = {
|
||||
libraryId,
|
||||
@ -84,8 +84,15 @@ function* walkBooks(root: string): Generator<string> {
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const extension = extname(entry.name).toLowerCase();
|
||||
if (extension === ".epub" || extension === ".pdf") {
|
||||
if (extension === ".epub" || extension === ".pdf" || extension === ".cbz") {
|
||||
yield path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" {
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
if (extension === ".epub") return "epub";
|
||||
if (extension === ".cbz") return "cbz";
|
||||
return "pdf";
|
||||
}
|
||||
|
||||
@ -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 ?? "";
|
||||
|
||||
@ -135,6 +135,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}`, {
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -21,3 +21,9 @@ export type DashboardData = {
|
||||
libraries: LibraryDto[];
|
||||
jobs: JobDto[];
|
||||
};
|
||||
|
||||
export type CbzPagesDto = {
|
||||
bookId: number;
|
||||
pageCount: number;
|
||||
pages: Array<{ page: number; name: string }>;
|
||||
};
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@ -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} />
|
||||
)}
|
||||
|
||||
70
apps/web/src/reader/CbzReader.tsx
Normal file
70
apps/web/src/reader/CbzReader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -249,6 +249,11 @@ h2 {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.format-cbz {
|
||||
background: var(--violet-glass);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.continue-grid,
|
||||
.library-list,
|
||||
.job-list {
|
||||
@ -481,7 +486,8 @@ input {
|
||||
}
|
||||
|
||||
.pdf-reader,
|
||||
.epub-reader {
|
||||
.epub-reader,
|
||||
.cbz-reader {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
@ -489,7 +495,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);
|
||||
@ -497,6 +504,11 @@ input {
|
||||
background: #f7f0df;
|
||||
}
|
||||
|
||||
.cbz-reader img {
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.epub-reader iframe {
|
||||
width: min(100%, 980px);
|
||||
height: calc(100vh - 170px);
|
||||
|
||||
Reference in New Issue
Block a user