From e5513d81eb2d8b2a6e22212cd51d0dbb1a35550b Mon Sep 17 00:00:00 2001 From: Git Agent Date: Sun, 23 Aug 2026 11:52:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(api,web):=20support=20CBZ=20=E2=80=94=20sc?= =?UTF-8?q?an,=20m=C3=A9tadonn=C3=A9es,=20lecteur=20d'albums?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/src/books/books.controller.ts | 13 ++++ apps/api/src/books/books.service.ts | 44 +++++++++++++- apps/api/src/common/cbz.ts | 46 ++++++++++++++ apps/api/src/database/database.service.ts | 74 ++++++++++++++++++++++- apps/api/src/database/schema.ts | 2 +- apps/api/src/scanner/metadata.test.ts | 20 ++++++ apps/api/src/scanner/metadata.ts | 14 +++++ apps/api/src/scanner/scanner.service.ts | 11 +++- apps/web/src/api/client.ts | 8 ++- apps/web/src/api/mockData.ts | 21 ++++++- apps/web/src/api/types.ts | 6 ++ apps/web/src/components/ui.tsx | 2 +- apps/web/src/pages/ReaderPage.tsx | 14 ++++- apps/web/src/reader/CbzReader.tsx | 70 +++++++++++++++++++++ apps/web/src/reader/locators.test.ts | 7 ++- apps/web/src/reader/locators.ts | 9 +++ apps/web/src/styles/app.css | 16 ++++- packages/shared/src/index.ts | 4 +- 18 files changed, 366 insertions(+), 15 deletions(-) create mode 100644 apps/api/src/common/cbz.ts create mode 100644 apps/web/src/reader/CbzReader.tsx diff --git a/apps/api/src/books/books.controller.ts b/apps/api/src/books/books.controller.ts index f73a961..ab668fd 100644 --- a/apps/api/src/books/books.controller.ts +++ b/apps/api/src/books/books.controller.ts @@ -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)); diff --git a/apps/api/src/books/books.service.ts b/apps/api/src/books/books.service.ts index d024e19..7c3295c 100644 --- a/apps/api/src/books/books.service.ts +++ b/apps/api/src/books/books.service.ts @@ -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`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) { diff --git a/apps/api/src/common/cbz.ts b/apps/api/src/common/cbz.ts new file mode 100644 index 0000000..81a173e --- /dev/null +++ b/apps/api/src/common/cbz.ts @@ -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() }; +} diff --git a/apps/api/src/database/database.service.ts b/apps/api/src/database/database.service.ts index 21b9ec0..2b48697 100644 --- a/apps/api/src/database/database.service.ts +++ b/apps/api/src/database/database.service.ts @@ -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; + `); + } } diff --git a/apps/api/src/database/schema.ts b/apps/api/src/database/schema.ts index 7a551e6..5192369 100644 --- a/apps/api/src/database/schema.ts +++ b/apps/api/src/database/schema.ts @@ -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(), diff --git a/apps/api/src/scanner/metadata.test.ts b/apps/api/src/scanner/metadata.test.ts index c20554d..9f2f25e 100644 --- a/apps/api/src/scanner/metadata.test.ts +++ b/apps/api/src/scanner/metadata.test.ts @@ -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"]); + }); +}); diff --git a/apps/api/src/scanner/metadata.ts b/apps/api/src/scanner/metadata.ts index c954722..7031d96 100644 --- a/apps/api/src/scanner/metadata.ts +++ b/apps/api/src/scanner/metadata.ts @@ -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)), diff --git a/apps/api/src/scanner/scanner.service.ts b/apps/api/src/scanner/scanner.service.ts index 98af707..cc7f9e3 100644 --- a/apps/api/src/scanner/scanner.service.ts +++ b/apps/api/src/scanner/scanner.service.ts @@ -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 { } 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"; +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 93825f2..2b186d1 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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 { + return request(`/books/${id}/pages`); + }, + cbzPageUrl(id: number, page: number): string { + return `${API_BASE}/books/${id}/pages/${page}`; + }, async progress(bookId: number): Promise { try { return await request(`/progress/${bookId}`, { diff --git a/apps/web/src/api/mockData.ts b/apps/web/src/api/mockData.ts index 0f70fc7..a06a39c 100644 --- a/apps/web/src/api/mockData.ts +++ b/apps/web/src/api/mockData.ts @@ -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) => ({ diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index cf08628..5efbb54 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -21,3 +21,9 @@ export type DashboardData = { libraries: LibraryDto[]; jobs: JobDto[]; }; + +export type CbzPagesDto = { + bookId: number; + pageCount: number; + pages: Array<{ page: number; name: string }>; +}; diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx index 00154cc..1f8988e 100644 --- a/apps/web/src/components/ui.tsx +++ b/apps/web/src/components/ui.tsx @@ -28,7 +28,7 @@ export function ErrorRibbon({ message }: { message?: string }) { return
{message}
; } -export function FormatPill({ format }: { format: "epub" | "pdf" }) { +export function FormatPill({ format }: { format: "epub" | "pdf" | "cbz" }) { return {format.toUpperCase()}; } diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx index d2a7f76..61fbb89 100644 --- a/apps/web/src/pages/ReaderPage.tsx +++ b/apps/web/src/pages/ReaderPage.tsx @@ -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 (
@@ -79,6 +87,8 @@ export function ReaderPage({ bookId }: { bookId: number }) {
) : book.format === "pdf" ? ( + ) : book.format === "cbz" ? ( + ) : ( )} diff --git a/apps/web/src/reader/CbzReader.tsx b/apps/web/src/reader/CbzReader.tsx new file mode 100644 index 0000000..e935bb3 --- /dev/null +++ b/apps/web/src/reader/CbzReader.tsx @@ -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(null); + const [error, setError] = useState(); + 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 ( +
+ {error || imageError ? ( +
+ {error ?? "Page CBZ indisponible."} + +
+ ) : ( + {currentName setImageError(true)} /> + )} +
+ + + {currentPage} / {pageCount} + + +
+
+ ); +} diff --git a/apps/web/src/reader/locators.test.ts b/apps/web/src/reader/locators.test.ts index 74fbd8a..f3ab54a 100644 --- a/apps/web/src/reader/locators.test.ts +++ b/apps/web/src/reader/locators.test.ts @@ -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); diff --git a/apps/web/src/reader/locators.ts b/apps/web/src/reader/locators.ts index 81c48a1..a25314a 100644 --- a/apps/web/src/reader/locators.ts +++ b/apps/web/src/reader/locators.ts @@ -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; } diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 554bd2f..ace46d4 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -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); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8aae7c1..a8cb937 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -94,7 +94,7 @@ export const BookSchema = z.object({ language: z.string().nullable(), publisher: z.string().nullable(), publishedDate: z.string().nullable(), - format: z.enum(["epub", "pdf"]), + format: z.enum(["epub", "pdf", "cbz"]), filePath: z.string(), coverPath: z.string().nullable(), fileSize: z.number().int().nonnegative(), @@ -106,7 +106,7 @@ export type BookDto = z.infer; export const BookQuerySchema = z.object({ q: z.string().optional(), - format: z.enum(["epub", "pdf"]).optional(), + format: z.enum(["epub", "pdf", "cbz"]).optional(), libraryId: z.coerce.number().int().positive().optional(), limit: z.coerce.number().int().min(1).max(100).default(50), offset: z.coerce.number().int().min(0).default(0)