fix(web,api): lecteur — worker pdf.js dédié, shell commun et préférences par livre

Régression worker PDF : le worker pdf.js est désormais instancié une
seule fois via un port dédié (?worker&url) et reconfiguré à chaque
montage, au lieu d'un workerSrc recalculé qui cassait le rendu.

- ReaderShell : chrome commun aux lecteurs (toolbar, zones de tap,
  statut) et contrat ReaderControls pour EPUB/PDF/CBZ
- préférences de lecture par livre (mode horizontal/vertical, fit) :
  table reader_preferences + migrations idempotentes, module API,
  DTO partagés, client web avec fallback localStorage hors-ligne

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-23 18:06:22 +02:00
parent 8024cab11c
commit 5de46a6f6d
19 changed files with 790 additions and 89 deletions

View File

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

View File

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

View File

@ -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 {}