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

@ -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<string, string>();
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: [] }), {

View File

@ -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<BookQueryDto>): 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<ReaderPreferencesDto>;
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<Session> {
try {
@ -176,6 +201,31 @@ export const api = {
fallback: { bookId, ...input, updatedAt: new Date().toISOString() }
});
},
async readerPreferences(bookId: number): Promise<ReaderPreferencesDto> {
try {
const preferences = await request<ReaderPreferencesDto>(`/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<ReaderPreferencesDto> {
writeLocalReaderPreferences(bookId, input);
try {
return await request<ReaderPreferencesDto>(`/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<ContinueItem[]> {
return request<ContinueItem[]>("/progress/continue", { fallback: mockContinue });
},

View File

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