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:
@ -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: [] }), {
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user