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:
@ -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 {}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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",
|
||||
{
|
||||
|
||||
26
apps/api/src/reader/reader-preferences.controller.ts
Normal file
26
apps/api/src/reader/reader-preferences.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
47
apps/api/src/reader/reader-preferences.service.ts
Normal file
47
apps/api/src/reader/reader-preferences.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/reader/reader.module.ts
Normal file
12
apps/api/src/reader/reader.module.ts
Normal 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 {}
|
||||
Reference in New Issue
Block a user