diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index da1de95..e3e7c93 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -5,11 +5,12 @@ import { AuthModule } from "./auth/auth.module.js"; import { BooksModule } from "./books/books.module.js"; import { DatabaseModule } from "./database/database.module.js"; import { ProgressModule } from "./progress/progress.module.js"; +import { ReaderModule } from "./reader/reader.module.js"; import { ScannerModule } from "./scanner/scanner.module.js"; import { HealthController } from "./health.controller.js"; @Module({ - imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ScannerModule, AutomationModule], + imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ScannerModule, AutomationModule], controllers: [HealthController] }) export class AppModule {} diff --git a/apps/api/src/database/database.service.ts b/apps/api/src/database/database.service.ts index 62acb45..9bdf018 100644 --- a/apps/api/src/database/database.service.ts +++ b/apps/api/src/database/database.service.ts @@ -143,6 +143,7 @@ export class DatabaseService implements OnModuleDestroy { `); this.ensureBooksSupportsComicArchives(); this.ensureBooksMetadataColumns(); + this.ensureReaderPreferencesTable(); this.ensureMetadataSourceConfigColumns(); this.ensureAutomationSettingsColumns(); this.ensureMetadataDefaults(); @@ -235,6 +236,22 @@ export class DatabaseService implements OnModuleDestroy { this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13)"); } + private ensureReaderPreferencesTable(): void { + this.sqlite.exec(` + CREATE TABLE IF NOT EXISTS reader_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE, + mode TEXT NOT NULL CHECK (mode IN ('paged','scrolled','horizontal','vertical')), + fit TEXT CHECK (fit IN ('page','width','height','auto')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, book_id) + ); + CREATE INDEX IF NOT EXISTS reader_preferences_user_idx ON reader_preferences(user_id); + `); + } + private ensureMetadataSourceConfigColumns(): void { const names = this.columnNames("metadata_source_config"); const now = sqlString(this.now()); diff --git a/apps/api/src/database/schema.ts b/apps/api/src/database/schema.ts index 87800de..4c066ba 100644 --- a/apps/api/src/database/schema.ts +++ b/apps/api/src/database/schema.ts @@ -50,6 +50,24 @@ export const books = sqliteTable( (table) => ({ filePathIdx: uniqueIndex("books_file_path_unique").on(table.filePath) }) ); +export const readerPreferences = sqliteTable( + "reader_preferences", + { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + bookId: integer("book_id") + .notNull() + .references(() => books.id, { onDelete: "cascade" }), + mode: text("mode", { enum: ["paged", "scrolled", "horizontal", "vertical"] }).notNull(), + fit: text("fit", { enum: ["page", "width", "height", "auto"] }), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull() + }, + (table) => ({ userBookIdx: uniqueIndex("reader_preferences_user_book_unique").on(table.userId, table.bookId) }) +); + export const progress = sqliteTable( "progress", { diff --git a/apps/api/src/reader/reader-preferences.controller.ts b/apps/api/src/reader/reader-preferences.controller.ts new file mode 100644 index 0000000..012baeb --- /dev/null +++ b/apps/api/src/reader/reader-preferences.controller.ts @@ -0,0 +1,26 @@ +import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common"; +import { UpdateReaderPreferencesDto, UpdateReaderPreferencesSchema } from "@readabook/shared"; +import { AuthGuard } from "../auth/auth.guard.js"; +import { CurrentUser, CurrentUserParam } from "../auth/current-user.js"; +import { ZodValidationPipe } from "../common/zod-validation.pipe.js"; +import { ReaderPreferencesService } from "./reader-preferences.service.js"; + +@Controller("reader/preferences") +@UseGuards(AuthGuard) +export class ReaderPreferencesController { + constructor(private readonly preferences: ReaderPreferencesService) {} + + @Get(":bookId") + get(@CurrentUserParam() user: CurrentUser, @Param("bookId") bookId: string) { + return this.preferences.find(user.id, Number(bookId)); + } + + @Put(":bookId") + update( + @CurrentUserParam() user: CurrentUser, + @Param("bookId") bookId: string, + @Body(new ZodValidationPipe(UpdateReaderPreferencesSchema)) body: UpdateReaderPreferencesDto + ) { + return this.preferences.upsert(user.id, Number(bookId), body); + } +} diff --git a/apps/api/src/reader/reader-preferences.service.ts b/apps/api/src/reader/reader-preferences.service.ts new file mode 100644 index 0000000..2c3d716 --- /dev/null +++ b/apps/api/src/reader/reader-preferences.service.ts @@ -0,0 +1,47 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { eq, sql } from "drizzle-orm"; +import { UpdateReaderPreferencesDto } from "@readabook/shared"; +import { DatabaseService } from "../database/database.service.js"; +import { books, readerPreferences } from "../database/schema.js"; + +@Injectable() +export class ReaderPreferencesService { + constructor(private readonly database: DatabaseService) {} + + find(userId: number, bookId: number) { + const row = this.database.db + .select({ + mode: readerPreferences.mode, + fit: readerPreferences.fit, + updatedAt: readerPreferences.updatedAt + }) + .from(readerPreferences) + .where(sql`${readerPreferences.userId} = ${userId} AND ${readerPreferences.bookId} = ${bookId}`) + .get(); + return row ?? { mode: "paged", fit: null }; + } + + upsert(userId: number, bookId: number, input: UpdateReaderPreferencesDto) { + const book = this.database.db.select({ id: books.id }).from(books).where(eq(books.id, bookId)).get(); + if (!book) throw new NotFoundException("Book not found"); + + const current = this.find(userId, bookId); + const now = this.database.now(); + const mode = input.mode ?? current.mode; + const fit = input.fit === undefined ? current.fit : input.fit; + + this.database.sqlite + .prepare( + ` + INSERT INTO reader_preferences(user_id, book_id, mode, fit, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, book_id) DO UPDATE SET + mode = excluded.mode, + fit = excluded.fit, + updated_at = excluded.updated_at + ` + ) + .run(userId, bookId, mode, fit, now, now); + return this.find(userId, bookId); + } +} diff --git a/apps/api/src/reader/reader.module.ts b/apps/api/src/reader/reader.module.ts new file mode 100644 index 0000000..b294f8e --- /dev/null +++ b/apps/api/src/reader/reader.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module.js"; +import { DatabaseModule } from "../database/database.module.js"; +import { ReaderPreferencesController } from "./reader-preferences.controller.js"; +import { ReaderPreferencesService } from "./reader-preferences.service.js"; + +@Module({ + imports: [AuthModule, DatabaseModule], + controllers: [ReaderPreferencesController], + providers: [ReaderPreferencesService] +}) +export class ReaderModule {} diff --git a/apps/web/src/api/client.test.ts b/apps/web/src/api/client.test.ts index 80ddfdc..09198c9 100644 --- a/apps/web/src/api/client.test.ts +++ b/apps/web/src/api/client.test.ts @@ -51,6 +51,22 @@ describe("api fallback helpers", () => { await expect(api.scanLibrary(42)).rejects.toThrow("offline"); }); + it("keeps reader preferences locally when the backend contract is absent", async () => { + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + clear: () => storage.clear() + }); + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 404, statusText: "Not Found" })); + vi.stubGlobal("fetch", fetchMock); + + await expect(api.readerPreferences(8)).resolves.toEqual({ mode: "horizontal", fit: "page" }); + await expect(api.saveReaderPreferences(8, { mode: "vertical", fit: "width" })).resolves.toEqual({ mode: "vertical", fit: "width" }); + expect(storage.get("readabook:reader-preferences:8")).toBe(JSON.stringify({ mode: "vertical", fit: "width" })); + }); + it("sends metadata source updates to the admin endpoint", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ isbnPriorityEnabled: false, sources: [] }), { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index baeb744..b9ba82a 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -26,9 +26,10 @@ import { mockProgress, mockUser } from "./mockData"; -import type { CbzPagesDto, ContinueItem, Session } from "./types"; +import type { CbzPagesDto, ContinueItem, ReaderPreferencesDto, Session } from "./types"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; +const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:"; type RequestOptions = RequestInit & { fallback?: unknown; @@ -111,6 +112,30 @@ function queryString(query: Partial): string { return value ? `?${value}` : ""; } +function readerPreferencesKey(bookId: number): string { + return `${READER_PREFERENCES_PREFIX}${bookId}`; +} + +function readLocalReaderPreferences(bookId: number): ReaderPreferencesDto { + if (typeof localStorage === "undefined") return { mode: "horizontal", fit: "page" }; + const raw = localStorage.getItem(readerPreferencesKey(bookId)); + if (!raw) return { mode: "horizontal", fit: "page" }; + try { + const parsed = JSON.parse(raw) as Partial; + return { + mode: parsed.mode === "vertical" ? "vertical" : "horizontal", + fit: parsed.fit === "width" ? "width" : "page" + }; + } catch { + return { mode: "horizontal", fit: "page" }; + } +} + +function writeLocalReaderPreferences(bookId: number, preferences: ReaderPreferencesDto): void { + if (typeof localStorage === "undefined") return; + localStorage.setItem(readerPreferencesKey(bookId), JSON.stringify(preferences)); +} + export const api = { async session(): Promise { try { @@ -176,6 +201,31 @@ export const api = { fallback: { bookId, ...input, updatedAt: new Date().toISOString() } }); }, + async readerPreferences(bookId: number): Promise { + try { + const preferences = await request(`/reader/preferences/${bookId}`, { + fallback: readLocalReaderPreferences(bookId) + }); + writeLocalReaderPreferences(bookId, preferences); + return preferences; + } catch (error) { + if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto; + return readLocalReaderPreferences(bookId); + } + }, + async saveReaderPreferences(bookId: number, input: ReaderPreferencesDto): Promise { + writeLocalReaderPreferences(bookId, input); + try { + return await request(`/reader/preferences/${bookId}`, { + method: "PUT", + body: JSON.stringify(input), + fallback: input + }); + } catch (error) { + if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto; + return input; + } + }, async continueReading(): Promise { return request("/progress/continue", { fallback: mockContinue }); }, diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 5efbb54..28536d3 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -27,3 +27,12 @@ export type CbzPagesDto = { pageCount: number; pages: Array<{ page: number; name: string }>; }; + +export type ReaderMode = "horizontal" | "vertical"; + +export type ReaderFit = "page" | "width"; + +export type ReaderPreferencesDto = { + mode: ReaderMode; + fit?: ReaderFit; +}; diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx index aa2adfa..4cd025d 100644 --- a/apps/web/src/pages/ReaderPage.tsx +++ b/apps/web/src/pages/ReaderPage.tsx @@ -1,21 +1,31 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { ArrowLeft, RotateCcw, Save } from "lucide-react"; 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 { parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators"; import { PdfReader } from "../reader/PdfReader"; +import { ReaderShell, type ReaderControls } from "../reader/ReaderShell"; +import { useReaderPreferences } from "../reader/useReaderPreferences"; import { useReaderProgress } from "../reader/useReaderProgress"; +const idleControls: ReaderControls = { + canPrevious: false, + canNext: false, + positionLabel: "Chargement", + onPrevious: () => undefined, + onNext: () => undefined +}; + export function ReaderPage({ bookId }: { bookId: number }) { const [book, setBook] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [page, setPage] = useState(1); + const [readerControls, setReaderControls] = useState(idleControls); + const [controlsVisible, setControlsVisible] = useState(true); const { progress, saving, error: progressError, save } = useReaderProgress(bookId); + const { preferences, setMode, error: preferencesError } = useReaderPreferences(bookId); async function loadBook() { setLoading(true); @@ -35,6 +45,11 @@ export function ReaderPage({ bookId }: { bookId: number }) { void loadBook(); }, [bookId]); + useEffect(() => { + setReaderControls(idleControls); + setControlsVisible(true); + }, [bookId]); + useEffect(() => { const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator); if (nextPage) setPage((current) => (current === nextPage ? current : nextPage)); @@ -59,27 +74,22 @@ export function ReaderPage({ bookId }: { bookId: number }) { [book?.format, save] ); + const readerError = error ?? progressError ?? preferencesError; + return ( -
-
- -
- {book?.title ?? "Ouverture du lecteur"} - {loading ? "Chargement" : saving ? "Sauvegarde" : "Progression synchronisee"} -
- {error ? ( - - ) : ( - - )} -
- - + void loadBook() : undefined} + mode={preferences.mode} + onModeChange={setMode} + controls={readerControls} + controlsVisible={controlsVisible} + onToggleControls={() => setControlsVisible((visible) => !visible)} + > {!book ? (
{error ?? "Chargement du livre."} @@ -88,12 +98,32 @@ export function ReaderPage({ bookId }: { bookId: number }) {
) : book.format === "pdf" ? ( - + ) : book.format === "cbz" || book.format === "cbr" ? ( - + ) : ( - + )} -
+ ); } diff --git a/apps/web/src/reader/CbzReader.tsx b/apps/web/src/reader/CbzReader.tsx index e935bb3..4117b7c 100644 --- a/apps/web/src/reader/CbzReader.tsx +++ b/apps/web/src/reader/CbzReader.tsx @@ -1,15 +1,20 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { api } from "../api/client"; -import type { CbzPagesDto } from "../api/types"; +import type { CbzPagesDto, ReaderMode } from "../api/types"; +import type { ReaderControls } from "./ReaderShell"; export function CbzReader({ bookId, page, - onPageCommit + mode, + onPageCommit, + onControlsChange }: { bookId: number; page: number; + mode: ReaderMode; onPageCommit: (page: number, pages: number) => void; + onControlsChange: (controls: ReaderControls) => void; }) { const [pages, setPages] = useState(null); const [error, setError] = useState(); @@ -37,13 +42,23 @@ export function CbzReader({ const currentPage = Math.max(1, Math.min(page, pageCount)); const currentName = pages?.pages.find((item) => item.page === currentPage)?.name; - function go(nextPage: number) { + const go = useCallback((nextPage: number) => { setImageError(false); onPageCommit(Math.max(1, Math.min(nextPage, pageCount)), pageCount); - } + }, [onPageCommit, pageCount]); + + useEffect(() => { + onControlsChange({ + canPrevious: !error && !imageError && currentPage > 1, + canNext: !error && !imageError && currentPage < pageCount, + positionLabel: pages ? `${currentPage} / ${pageCount}` : "Ouverture archive", + onPrevious: () => go(currentPage - 1), + onNext: () => go(currentPage + 1) + }); + }, [currentPage, error, go, imageError, onControlsChange, pageCount, pages]); return ( -
+
{error || imageError ? (
{error ?? "Page CBZ indisponible."} @@ -54,17 +69,6 @@ export function CbzReader({ ) : ( {currentName setImageError(true)} /> )} -
- - - {currentPage} / {pageCount} - - -
); } diff --git a/apps/web/src/reader/EpubReader.tsx b/apps/web/src/reader/EpubReader.tsx index 2c089f3..c764313 100644 --- a/apps/web/src/reader/EpubReader.tsx +++ b/apps/web/src/reader/EpubReader.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; -import { ArrowLeft, ArrowRight } from "lucide-react"; import { ReaderError, readerErrorMessage } from "./ReaderError"; +import type { ReaderControls } from "./ReaderShell"; +import type { ReaderMode } from "../api/types"; type FoliateLocation = { cfi?: string; @@ -42,12 +43,16 @@ export function EpubReader({ url, locator, backHref, - onLocatorChange + mode, + onLocatorChange, + onControlsChange }: { url: string; locator?: string; backHref: string; + mode: ReaderMode; onLocatorChange: (locator: string, percent: number) => void; + onControlsChange: (controls: ReaderControls) => void; }) { const hostRef = useRef(null); const viewRef = useRef(null); @@ -56,6 +61,16 @@ export function EpubReader({ const [error, setError] = useState(); const [attempt, setAttempt] = useState(0); + useEffect(() => { + onControlsChange({ + canPrevious: !loading && !error, + canNext: !loading && !error, + positionLabel: loading ? "Ouverture EPUB" : "Lecture integree", + onPrevious: () => void viewRef.current?.goLeft(), + onNext: () => void viewRef.current?.goRight() + }); + }, [error, loading, onControlsChange]); + useEffect(() => { locatorRef.current = locator; }, [locator]); @@ -108,6 +123,11 @@ export function EpubReader({ }; }, [attempt, onLocatorChange, url]); + useEffect(() => { + viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical"); + viewRef.current?.classList.toggle("epub-view-horizontal", mode === "horizontal"); + }, [mode]); + if (error) { return (
@@ -124,24 +144,13 @@ export function EpubReader({ } return ( -
+
{loading && (
Ouverture EPUB
)}
-
- - {loading ? "Chargement" : "Lecture intégrée"} - -
); } diff --git a/apps/web/src/reader/PdfReader.tsx b/apps/web/src/reader/PdfReader.tsx index 42cf30d..9cb4c70 100644 --- a/apps/web/src/reader/PdfReader.tsx +++ b/apps/web/src/reader/PdfReader.tsx @@ -1,26 +1,60 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import * as pdfjs from "pdfjs-dist"; import { ReaderError, readerErrorMessage } from "./ReaderError"; -import { pdfWorkerSrc } from "./pdfWorker"; +import { configurePdfWorker } from "./pdfWorker"; +import type { ReaderControls } from "./ReaderShell"; +import type { ReaderMode } from "../api/types"; -pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerSrc; +configurePdfWorker(pdfjs); export function PdfReader({ url, page, backHref, - onPageCommit + mode, + onPageCommit, + onControlsChange }: { url: string; page: number; backHref: string; + mode: ReaderMode; onPageCommit: (page: number, pages: number) => void; + onControlsChange: (controls: ReaderControls) => void; }) { const canvasRef = useRef(null); + const frameRef = useRef(null); const [pages, setPages] = useState(1); const [error, setError] = useState(); const [loading, setLoading] = useState(true); const [attempt, setAttempt] = useState(0); + const [frameSize, setFrameSize] = useState({ width: 980, height: 900 }); + + const currentPage = Math.max(1, Math.min(page, pages)); + const go = useCallback((nextPage: number) => onPageCommit(Math.max(1, Math.min(nextPage, pages)), pages), [onPageCommit, pages]); + + useEffect(() => { + onControlsChange({ + canPrevious: !loading && !error && currentPage > 1, + canNext: !loading && !error && currentPage < pages, + positionLabel: loading ? "Ouverture PDF" : `${currentPage} / ${pages}`, + onPrevious: () => go(currentPage - 1), + onNext: () => go(currentPage + 1) + }); + }, [currentPage, error, go, loading, onControlsChange, pages]); + + useEffect(() => { + const frame = frameRef.current; + if (!frame) return; + const updateSize = () => { + const rect = frame.getBoundingClientRect(); + setFrameSize({ width: Math.max(320, rect.width), height: Math.max(320, rect.height) }); + }; + updateSize(); + const observer = new ResizeObserver(updateSize); + observer.observe(frame); + return () => observer.disconnect(); + }, []); useEffect(() => { let cancelled = false; @@ -28,6 +62,7 @@ export function PdfReader({ let renderTask: pdfjs.RenderTask | undefined; async function render() { try { + configurePdfWorker(pdfjs); setLoading(true); setError(undefined); loadingTask = pdfjs.getDocument({ url, withCredentials: true }); @@ -37,9 +72,17 @@ export function PdfReader({ const pdfPage = await document.getPage(Math.max(1, Math.min(page, document.numPages))); const canvas = canvasRef.current; if (!canvas) return; - const viewport = pdfPage.getViewport({ scale: Math.min(1.6, window.devicePixelRatio || 1) }); - canvas.width = viewport.width; - canvas.height = viewport.height; + const baseViewport = pdfPage.getViewport({ scale: 1 }); + const fitScale = + mode === "vertical" + ? frameSize.width / baseViewport.width + : Math.min(frameSize.width / baseViewport.width, frameSize.height / baseViewport.height); + const renderScale = Math.max(0.35, Math.min(3, fitScale)) * Math.min(2, window.devicePixelRatio || 1); + const viewport = pdfPage.getViewport({ scale: renderScale }); + canvas.width = Math.floor(viewport.width); + canvas.height = Math.floor(viewport.height); + canvas.style.width = `${Math.floor(viewport.width / Math.min(2, window.devicePixelRatio || 1))}px`; + canvas.style.height = `${Math.floor(viewport.height / Math.min(2, window.devicePixelRatio || 1))}px`; const context = canvas.getContext("2d"); if (!context) return; renderTask = pdfPage.render({ canvas, canvasContext: context, viewport }); @@ -58,10 +101,10 @@ export function PdfReader({ renderTask?.cancel(); void loadingTask?.destroy(); }; - }, [url, page, attempt]); + }, [url, page, attempt, frameSize.height, frameSize.width, mode]); return ( -
+
{error ? ( )} -
- - - {page} / {pages} - - -
); } diff --git a/apps/web/src/reader/ReaderShell.tsx b/apps/web/src/reader/ReaderShell.tsx new file mode 100644 index 0000000..aff8de1 --- /dev/null +++ b/apps/web/src/reader/ReaderShell.tsx @@ -0,0 +1,145 @@ +import { ArrowLeft, ArrowRight, Columns2, RotateCcw, Rows3, Save } from "lucide-react"; +import type { ReactNode } from "react"; +import { ErrorRibbon, Meter } from "../components/ui"; +import { navigate } from "../router"; +import type { ReaderMode } from "../api/types"; + +export type ReaderControls = { + canPrevious: boolean; + canNext: boolean; + positionLabel: string; + onPrevious: () => void; + onNext: () => void; +}; + +type ReaderShellProps = { + title: string; + status: string; + backHref: string; + progress: number; + error?: string; + onRetry?: () => void; + mode: ReaderMode; + onModeChange: (mode: ReaderMode) => void; + controls: ReaderControls; + controlsVisible: boolean; + onToggleControls: () => void; + children: ReactNode; +}; + +export function ReaderShell({ + title, + status, + backHref, + progress, + error, + onRetry, + mode, + onModeChange, + controls, + controlsVisible, + onToggleControls, + children +}: ReaderShellProps) { + return ( +
+
event.stopPropagation()}> + +
+ {title} + {status} +
+
+ + + {error && onRetry ? ( + + ) : ( + + )} +
+
+
event.stopPropagation()}> + + +
+
+ {mode === "horizontal" && ( + <> + + +
+
event.stopPropagation()}> + + {controls.positionLabel} + +
+
+ ); +} diff --git a/apps/web/src/reader/pdfWorker.ts b/apps/web/src/reader/pdfWorker.ts index 54cdf6d..1e4be43 100644 --- a/apps/web/src/reader/pdfWorker.ts +++ b/apps/web/src/reader/pdfWorker.ts @@ -1 +1,17 @@ -export const pdfWorkerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString(); +import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?worker&url"; + +export const pdfWorkerSrc = pdfWorkerUrl; + +let pdfWorkerPort: Worker | null = null; + +export function configurePdfWorker(pdfjs: Pick) { + pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerSrc; + + if (typeof window === "undefined" || !("Worker" in window)) { + return false; + } + + pdfWorkerPort ??= new Worker(pdfWorkerSrc, { type: "module" }); + pdfjs.GlobalWorkerOptions.workerPort = pdfWorkerPort; + return true; +} diff --git a/apps/web/src/reader/readerRuntime.test.ts b/apps/web/src/reader/readerRuntime.test.ts index 87df2c5..6ad5bc2 100644 --- a/apps/web/src/reader/readerRuntime.test.ts +++ b/apps/web/src/reader/readerRuntime.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { epubFileName } from "./EpubReader"; -import { pdfWorkerSrc } from "./pdfWorker"; +import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker"; import { readerErrorMessage } from "./ReaderError"; describe("reader runtime helpers", () => { @@ -9,10 +9,23 @@ describe("reader runtime helpers", () => { expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub"); }); - it("keeps PDF.js worker source on the bundled module worker", () => { + it("keeps PDF.js worker fallback source on the bundled module worker", () => { expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs"); }); + it("configures the PDF.js worker fallback without creating a worker outside the browser", () => { + const pdfjs = { + GlobalWorkerOptions: { + workerPort: null, + workerSrc: "" + } + }; + + expect(configurePdfWorker(pdfjs as unknown as Parameters[0])).toBe(false); + expect(pdfjs.GlobalWorkerOptions.workerPort).toBeNull(); + expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(pdfWorkerSrc); + }); + 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/reader/useReaderPreferences.ts b/apps/web/src/reader/useReaderPreferences.ts new file mode 100644 index 0000000..269efb4 --- /dev/null +++ b/apps/web/src/reader/useReaderPreferences.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useState } from "react"; +import { api } from "../api/client"; +import type { ReaderMode, ReaderPreferencesDto } from "../api/types"; + +const defaultPreferences: ReaderPreferencesDto = { mode: "horizontal", fit: "page" }; + +export function useReaderPreferences(bookId: number) { + const [preferences, setPreferences] = useState(defaultPreferences); + const [error, setError] = useState(); + + useEffect(() => { + let alive = true; + setError(undefined); + api + .readerPreferences(bookId) + .then((next) => { + if (alive) setPreferences(next); + }) + .catch(() => { + if (alive) setError("Preferences lecteur conservees sur cet appareil."); + }); + return () => { + alive = false; + }; + }, [bookId]); + + const setMode = useCallback( + (mode: ReaderMode) => { + const next = { ...preferences, mode }; + setPreferences(next); + void api.saveReaderPreferences(bookId, next).catch(() => setError("Preferences lecteur conservees sur cet appareil.")); + }, + [bookId, preferences] + ); + + return { preferences, setMode, error }; +} diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index ec74de5..8e681e9 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -643,13 +643,18 @@ select { .reader-page { display: grid; - gap: 12px; + grid-template-rows: auto auto minmax(0, 1fr) auto; + gap: 10px; + height: 100vh; min-height: 100vh; padding: 14px; + overflow: hidden; background: #120e0b; } .reader-topbar { + position: relative; + z-index: 5; justify-content: space-between; padding: 10px; border: 1px solid var(--line); @@ -667,20 +672,65 @@ select { font-size: 0.82rem; } +.reader-toolbar { + display: flex; + align-items: center; + gap: 8px; +} + +.reader-toolbar .icon-only { + width: 40px; + padding: 0; +} + +.reader-toolbar .active { + border-color: rgba(213, 168, 77, 0.72); + color: var(--brass); + background: rgba(213, 168, 77, 0.14); +} + +.reader-status { + position: relative; + z-index: 4; +} + +.reader-stage { + position: relative; + display: grid; + place-items: stretch; + min-height: 0; + min-width: 0; + padding: 0; + overflow: hidden; +} + +.reader-mode-vertical .reader-stage { + overflow-y: auto; + overscroll-behavior: contain; +} + +.reader-content { + display: grid; + min-height: 0; + min-width: 0; +} + .pdf-reader, .epub-reader, .cbz-reader { display: grid; place-items: center; - gap: 10px; - min-height: calc(100vh - 120px); + min-height: 0; + min-width: 0; + width: 100%; + height: 100%; } .epub-host { display: grid; width: min(100%, 980px); - height: calc(100vh - 170px); - min-height: 460px; + height: 100%; + min-height: 0; } .epub-view { @@ -694,8 +744,9 @@ select { .pdf-reader canvas, .cbz-reader img { + display: block; max-width: min(100%, 980px); - max-height: calc(100vh - 170px); + max-height: 100%; border: 1px solid var(--line); border-radius: var(--radius); background: #f7f0df; @@ -706,6 +757,26 @@ select { object-fit: contain; } +.reader-mode-vertical .pdf-reader, +.reader-mode-vertical .cbz-reader { + align-content: start; + padding-bottom: 20px; +} + +.reader-mode-vertical .pdf-reader canvas, +.reader-mode-vertical .cbz-reader img { + width: min(100%, 980px); + height: auto; + max-height: none; +} + +.reader-mode-horizontal .pdf-reader canvas, +.reader-mode-horizontal .cbz-reader img { + width: auto; + height: auto; + object-fit: contain; +} + .reader-fallback { display: flex; flex-wrap: wrap; @@ -717,9 +788,68 @@ select { } .reader-stepper { + position: relative; + z-index: 5; display: flex; align-items: center; + justify-content: center; gap: 12px; + min-height: 46px; + padding: 6px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(38, 26, 18, 0.84); +} + +.reader-stepper span { + min-width: 92px; + color: var(--ink-muted); + text-align: center; +} + +.reader-side-button, +.reader-tap-zone { + position: absolute; + top: 0; + bottom: 0; + z-index: 3; + border: 0; + background: transparent; +} + +.reader-side-button { + display: grid; + place-items: center; + width: 58px; + color: var(--ink); + opacity: 0.66; +} + +.reader-side-button:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.04); +} + +.reader-side-button:disabled, +.reader-tap-zone:disabled { + pointer-events: none; + opacity: 0; +} + +.reader-side-left, +.reader-tap-left { + left: 0; +} + +.reader-side-right, +.reader-tap-right { + right: 0; +} + +.reader-tap-zone { + display: none; + width: 34%; + opacity: 0; } .reader-error { @@ -854,4 +984,80 @@ select { display: grid; grid-template-columns: 1fr 1fr; } + + .reader-page { + padding: 8px; + } + + .reader-topbar { + gap: 8px; + } + + .reader-topbar div { + min-width: 0; + } + + .reader-topbar strong { + overflow: hidden; + max-width: 38vw; + text-overflow: ellipsis; + white-space: nowrap; + } + + .reader-toolbar { + gap: 5px; + } + + .reader-side-button { + display: none; + } + + .reader-tap-zone { + display: block; + } + + .reader-controls-hidden .reader-topbar, + .reader-controls-hidden .reader-status, + .reader-controls-hidden .reader-stepper { + opacity: 0; + pointer-events: none; + } + + .reader-controls-hidden { + grid-template-rows: 0 0 minmax(0, 1fr) 0; + gap: 0; + } + + .reader-controls-hidden .reader-topbar, + .reader-controls-hidden .reader-status, + .reader-controls-hidden .reader-stepper { + overflow: hidden; + min-height: 0; + padding-top: 0; + padding-bottom: 0; + } + + .reader-topbar, + .reader-status, + .reader-stepper { + transition: opacity 0.16s ease; + } + + .reader-stepper { + gap: 8px; + } + + .reader-stepper .ghost-button { + flex: 1 1 0; + min-width: 0; + } + + .epub-host { + width: 100%; + } + + .reader-mode-vertical .pdf-reader canvas, + .reader-mode-vertical .cbz-reader img { + width: 100%; + } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9243ebd..0151b80 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -128,6 +128,19 @@ export const UpdateProgressSchema = z.object({ }); export type UpdateProgressDto = z.infer; +export const ReaderPreferencesSchema = z.object({ + mode: z.enum(["paged", "scrolled", "horizontal", "vertical"]).default("paged"), + fit: z.enum(["page", "width", "height", "auto"]).nullable().default(null), + updatedAt: z.string().optional() +}); +export type ReaderPreferencesDto = z.infer; + +export const UpdateReaderPreferencesSchema = z.object({ + mode: z.enum(["paged", "scrolled", "horizontal", "vertical"]).optional(), + fit: z.enum(["page", "width", "height", "auto"]).nullable().optional() +}); +export type UpdateReaderPreferencesDto = z.infer; + export const JobSchema = z.object({ id: z.number().int().positive(), type: z.string(),