From e94524f11918e7e9bbdd1e376ba060215b8151b3 Mon Sep 17 00:00:00 2001 From: Git Agent Date: Sun, 23 Aug 2026 10:01:54 +0200 Subject: [PATCH 01/53] =?UTF-8?q?fix(web,api):=20d=C3=A9bloquer=20la=20bou?= =?UTF-8?q?cle=20de=20chargement=20'Inventaire=20en=20cours'=20(Recherche/?= =?UTF-8?q?Admin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web: gestion d'erreur et d'état vide sur SearchPage/AdminPage - web: client API — traitement des réponses sans contenu/erreurs réseau - api: jobs.service — éviter le statut de scan perpétuellement en cours - tests: client.test.ts Refs: #12 (QA non-GREEN faute d'exécution, pas d'échec réel) --- apps/api/src/jobs/jobs.service.ts | 22 +++++- apps/web/src/api/client.test.ts | 12 +++ apps/web/src/api/client.ts | 4 + apps/web/src/pages/AdminPage.tsx | 117 +++++++++++++++++++++--------- apps/web/src/pages/SearchPage.tsx | 40 ++++++++-- apps/web/src/styles/app.css | 9 +++ 6 files changed, 159 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/api/client.test.ts diff --git a/apps/api/src/jobs/jobs.service.ts b/apps/api/src/jobs/jobs.service.ts index f219825..cab98a5 100644 --- a/apps/api/src/jobs/jobs.service.ts +++ b/apps/api/src/jobs/jobs.service.ts @@ -1,12 +1,16 @@ -import { Injectable } from "@nestjs/common"; -import { desc, eq } from "drizzle-orm"; +import { Injectable, OnModuleInit } from "@nestjs/common"; +import { desc, eq, inArray } from "drizzle-orm"; import { DatabaseService } from "../database/database.service.js"; import { jobs } from "../database/schema.js"; @Injectable() -export class JobsService { +export class JobsService implements OnModuleInit { constructor(private readonly database: DatabaseService) {} + onModuleInit(): void { + this.failInterruptedJobs(); + } + create(type: string, detail?: string) { const now = this.database.now(); return this.database.db @@ -43,4 +47,16 @@ export class JobsService { list(limit = 50) { return this.database.db.select().from(jobs).orderBy(desc(jobs.createdAt)).limit(limit).all(); } + + private failInterruptedJobs(): void { + this.database.db + .update(jobs) + .set({ + status: "failed", + error: "Job interrupted before completion, most likely by API shutdown or restart", + updatedAt: this.database.now() + }) + .where(inArray(jobs.status, ["queued", "running"])) + .run(); + } } diff --git a/apps/web/src/api/client.test.ts b/apps/web/src/api/client.test.ts new file mode 100644 index 0000000..a003086 --- /dev/null +++ b/apps/web/src/api/client.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { ApiFallbackError, getApiFallback } from "./client"; + +describe("api fallback helpers", () => { + it("extracts typed fallback payloads", () => { + expect(getApiFallback(new ApiFallbackError("offline", ["demo"]))).toEqual(["demo"]); + }); + + it("ignores non fallback errors", () => { + expect(getApiFallback(new Error("boom"))).toBeUndefined(); + }); +}); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 25830e2..c128780 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -28,6 +28,10 @@ export class ApiFallbackError extends Error { } } +export function getApiFallback(error: unknown): T | undefined { + return error instanceof ApiFallbackError ? (error.fallback as T) : undefined; +} + async function request(path: string, options: RequestOptions = {}): Promise { try { const response = await fetch(`${API_BASE}${path}`, { diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx index bfc3106..f737fd4 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -1,26 +1,51 @@ import { FormEvent, useEffect, useState } from "react"; import { Play, Plus } from "lucide-react"; import type { JobDto, LibraryDto, UserDto } from "@readabook/shared"; -import { api } from "../api/client"; -import { ErrorRibbon, LoadingState, Panel } from "../components/ui"; +import { api, getApiFallback } from "../api/client"; +import { EmptyState, ErrorRibbon, LoadingState, Panel } from "../components/ui"; export function AdminPage() { - const [libraries, setLibraries] = useState(null); + const [libraries, setLibraries] = useState([]); const [jobs, setJobs] = useState([]); const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); const [name, setName] = useState("Bibliotheque locale"); const [path, setPath] = useState("/library"); const [error, setError] = useState(); async function refresh() { - const [nextLibraries, nextJobs, nextUsers] = await Promise.all([api.libraries(), api.jobs(), api.users()]); - setLibraries(nextLibraries); - setJobs(nextJobs); - setUsers(nextUsers); + setLoading(true); + setError(undefined); + const [libraryResult, jobResult, userResult] = await Promise.allSettled([api.libraries(), api.jobs(), api.users()]); + const errors: string[] = []; + + if (libraryResult.status === "fulfilled") { + setLibraries(libraryResult.value); + } else { + setLibraries(getApiFallback(libraryResult.reason) ?? []); + errors.push("bibliotheques"); + } + + if (jobResult.status === "fulfilled") { + setJobs(jobResult.value); + } else { + setJobs(getApiFallback(jobResult.reason) ?? []); + errors.push("travaux"); + } + + if (userResult.status === "fulfilled") { + setUsers(userResult.value); + } else { + setUsers(getApiFallback(userResult.reason) ?? []); + errors.push("comptes"); + } + + setError(errors.length ? `Donnees admin degradees : ${errors.join(", ")}.` : undefined); + setLoading(false); } useEffect(() => { - refresh().catch((refreshError) => setError(refreshError instanceof Error ? refreshError.message : "Administration indisponible")); + void refresh(); }, []); async function createLibrary(event: FormEvent) { @@ -30,7 +55,9 @@ export function AdminPage() { await api.createLibrary({ name, path, enabled: true }); await refresh(); } catch (createError) { - setError(createError instanceof Error ? createError.message : "Creation impossible"); + const fallback = getApiFallback(createError); + if (fallback) setLibraries((current) => [fallback, ...current]); + setError(fallback ? "Creation en mode secours, synchronisation a retenter." : "Creation impossible"); } } @@ -40,20 +67,28 @@ export function AdminPage() { await api.scanLibrary(id); await refresh(); } catch (scanError) { - setError(scanError instanceof Error ? scanError.message : "Scan impossible"); + const fallback = getApiFallback(scanError); + if (fallback) setJobs((current) => [fallback, ...current]); + setError(fallback ? "Scan place en file de secours, statut a verifier." : "Scan impossible"); } } - if (!libraries) return ; - return (

Administration

- {users.length} comptes + {loading ? "chargement" : `${users.length} comptes`}
+ {error && ( +
+ Les formulaires restent disponibles. + +
+ )}
-
- {jobs.map((job) => ( -
- {job.type} - {job.status} -
- ))} -
+ {loading && !jobs.length ? ( + + ) : jobs.length ? ( +
+ {jobs.map((job) => ( +
+ {job.type} + {job.status} +
+ ))} +
+ ) : ( + + )} -
- {libraries.map((library) => ( -
-
- {library.name} - {library.path} + {loading && !libraries.length ? ( + + ) : libraries.length ? ( +
+ {libraries.map((library) => ( +
+
+ {library.name} + {library.path} +
+ {library.enabled ? "actif" : "pause"} +
- {library.enabled ? "actif" : "pause"} - -
- ))} -
+ ))} +
+ ) : ( + + )}
); diff --git a/apps/web/src/pages/SearchPage.tsx b/apps/web/src/pages/SearchPage.tsx index e9a2f32..802f63d 100644 --- a/apps/web/src/pages/SearchPage.tsx +++ b/apps/web/src/pages/SearchPage.tsx @@ -1,22 +1,37 @@ import { FormEvent, useEffect, useState } from "react"; import { Search } from "lucide-react"; import type { BookDto } from "@readabook/shared"; -import { api } from "../api/client"; +import { api, getApiFallback } from "../api/client"; import { BookCard } from "../components/BookCard"; import { EmptyState, LoadingState, Panel } from "../components/ui"; export function SearchPage() { const [query, setQuery] = useState(""); - const [books, setBooks] = useState(null); + const [books, setBooks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + + async function loadBooks(nextQuery = query) { + setLoading(true); + setError(undefined); + try { + setBooks(nextQuery.trim() ? await api.search(nextQuery.trim()) : await api.books()); + } catch (loadError) { + const fallback = getApiFallback(loadError); + setBooks(fallback ?? []); + setError(fallback ? "Catalogue indisponible, affichage de secours." : "Recherche indisponible."); + } finally { + setLoading(false); + } + } useEffect(() => { - api.books().then(setBooks); + void loadBooks(""); }, []); async function submit(event: FormEvent) { event.preventDefault(); - setBooks(null); - setBooks(query.trim() ? await api.search(query.trim()) : await api.books()); + await loadBooks(query); } return ( @@ -29,8 +44,16 @@ export function SearchPage() { Chercher + {error && ( +
+ {error} + +
+ )}
- {!books ? ( + {loading && !books.length ? ( ) : books.length ? (
@@ -40,7 +63,10 @@ export function SearchPage() {
) : ( - + )} diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 092a6d3..8918f17 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -326,6 +326,15 @@ input { background: rgba(169, 72, 52, 0.18); } +.retry-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 10px 0; + color: var(--ink-muted); +} + .empty-state, .loading-state { display: grid; From 85b56ef3b2dfc0183a334a33c63a78c967c61a05 Mon Sep 17 00:00:00 2001 From: Git Agent Date: Sun, 23 Aug 2026 10:32:44 +0200 Subject: [PATCH 02/53] =?UTF-8?q?fix(web,api):=20UI=20fiche=20livre=20et?= =?UTF-8?q?=20lecteur=20=E2=80=94=20progression,=20locators=20EPUB/PDF,=20?= =?UTF-8?q?styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web: BookPage/ReaderPage enrichis, sauvegarde de progression lecteur - web: locators EPUB/PDF normalisés (+ tests), EpubReader/PdfReader ajustés - web: nginx et service worker ajustés - api: progress — corrections contrôleur/service Refs: #13 --- apps/api/src/progress/progress.controller.ts | 2 +- apps/api/src/progress/progress.service.ts | 13 +++-- apps/web/nginx/default.conf | 31 +++++++--- apps/web/public/sw.js | 20 ++++++- apps/web/src/pages/BookPage.tsx | 49 +++++++++++++--- apps/web/src/pages/ReaderPage.tsx | 60 +++++++++++++++----- apps/web/src/reader/EpubReader.tsx | 16 ++++-- apps/web/src/reader/PdfReader.tsx | 22 +++++-- apps/web/src/reader/locators.test.ts | 19 +++++++ apps/web/src/reader/locators.ts | 10 ++++ apps/web/src/reader/useReaderProgress.ts | 27 ++++++++- apps/web/src/styles/app.css | 39 ++++++++++++- 12 files changed, 254 insertions(+), 54 deletions(-) create mode 100644 apps/web/src/reader/locators.test.ts create mode 100644 apps/web/src/reader/locators.ts diff --git a/apps/api/src/progress/progress.controller.ts b/apps/api/src/progress/progress.controller.ts index 808f877..5f8839e 100644 --- a/apps/api/src/progress/progress.controller.ts +++ b/apps/api/src/progress/progress.controller.ts @@ -17,7 +17,7 @@ export class ProgressController { @Get(":bookId") get(@CurrentUserParam() user: CurrentUser, @Param("bookId") bookId: string) { - return this.progress.get(user.id, Number(bookId)); + return this.progress.find(user.id, Number(bookId)); } @Put(":bookId") diff --git a/apps/api/src/progress/progress.service.ts b/apps/api/src/progress/progress.service.ts index 6e40277..1aa4d57 100644 --- a/apps/api/src/progress/progress.service.ts +++ b/apps/api/src/progress/progress.service.ts @@ -30,6 +30,14 @@ export class ProgressService { } get(userId: number, bookId: number) { + const row = this.find(userId, bookId); + if (!row) { + throw new NotFoundException("Progress not found"); + } + return row; + } + + find(userId: number, bookId: number) { const row = this.database.db .select({ bookId: progress.bookId, @@ -40,10 +48,7 @@ export class ProgressService { .from(progress) .where(sql`${progress.userId} = ${userId} AND ${progress.bookId} = ${bookId}`) .get(); - if (!row) { - throw new NotFoundException("Progress not found"); - } - return row; + return row ?? null; } continueReading(userId: number) { diff --git a/apps/web/nginx/default.conf b/apps/web/nginx/default.conf index ef87ba5..b6d7bed 100644 --- a/apps/web/nginx/default.conf +++ b/apps/web/nginx/default.conf @@ -4,26 +4,41 @@ server { root /usr/share/nginx/html; index index.html; - location /auth/ { - proxy_pass http://api:3000/auth/; + location = /sw.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + try_files /sw.js =404; + } + + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + try_files /index.html =404; + } + + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location ^~ /auth { + proxy_pass http://api:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } - location /admin/ { - proxy_pass http://api:3000/admin/; + location ^~ /admin { + proxy_pass http://api:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } - location /books/ { - proxy_pass http://api:3000/books/; + location ^~ /books { + proxy_pass http://api:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } - location /progress/ { - proxy_pass http://api:3000/progress/; + location ^~ /progress { + proxy_pass http://api:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index 6d7910e..ef6df93 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -1,5 +1,5 @@ -const CACHE_NAME = "readabook-shell-v1"; -const SHELL = ["/", "/home", "/manifest.webmanifest", "/icons/readabook.svg"]; +const CACHE_NAME = "readabook-shell-v2"; +const SHELL = ["/", "/index.html", "/manifest.webmanifest", "/icons/readabook.svg"]; self.addEventListener("install", (event) => { event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL))); @@ -18,5 +18,19 @@ self.addEventListener("fetch", (event) => { if (event.request.method !== "GET" || ["/auth", "/admin", "/books", "/progress"].some((path) => url.pathname.startsWith(path))) { return; } - event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((hit) => hit || caches.match("/")))); + if (event.request.mode === "navigate") { + event.respondWith( + fetch(event.request) + .then((response) => { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put("/index.html", copy)); + return response; + }) + .catch(() => caches.match("/index.html").then((hit) => hit || caches.match("/"))) + ); + return; + } + event.respondWith( + caches.match(event.request).then((hit) => hit || fetch(event.request)) + ); }); diff --git a/apps/web/src/pages/BookPage.tsx b/apps/web/src/pages/BookPage.tsx index 440adb1..9ec0865 100644 --- a/apps/web/src/pages/BookPage.tsx +++ b/apps/web/src/pages/BookPage.tsx @@ -1,27 +1,55 @@ import { useEffect, useState } from "react"; -import { BookOpen, LibraryBig } from "lucide-react"; +import { BookOpen, LibraryBig, RotateCcw } from "lucide-react"; import type { BookDto, ProgressDto } from "@readabook/shared"; -import { api } from "../api/client"; -import { FormatPill, LoadingState, Meter, Panel } from "../components/ui"; +import { api, getApiFallback } from "../api/client"; +import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui"; import { navigate } from "../router"; export function BookPage({ bookId }: { bookId: number }) { const [book, setBook] = useState(null); const [progress, setProgress] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + + async function loadBook() { + setLoading(true); + setError(undefined); + try { + const [nextBook, nextProgress] = await Promise.all([api.book(bookId), api.progress(bookId)]); + setBook(nextBook); + setProgress(nextProgress); + } catch (loadError) { + const fallback = getApiFallback(loadError); + setBook(fallback ?? null); + setProgress(null); + setError(fallback ? "Fiche chargee en mode secours." : "Fiche livre indisponible."); + } finally { + setLoading(false); + } + } useEffect(() => { let alive = true; - Promise.all([api.book(bookId), api.progress(bookId)]).then(([nextBook, nextProgress]) => { + loadBook().finally(() => { if (!alive) return; - setBook(nextBook); - setProgress(nextProgress); }); return () => { alive = false; }; }, [bookId]); - if (!book) return ; + if (loading && !book) return ; + if (!book) { + return ( + + + + + ); + } return (
@@ -29,6 +57,7 @@ export function BookPage({ bookId }: { bookId: number }) { {book.coverPath ? : } +
{book.language ?? "langue inconnue"} @@ -46,6 +75,12 @@ export function BookPage({ bookId }: { bookId: number }) { Rayon + {error && ( + + )}
diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx index 21763fc..d2a7f76 100644 --- a/apps/web/src/pages/ReaderPage.tsx +++ b/apps/web/src/pages/ReaderPage.tsx @@ -1,54 +1,84 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { ArrowLeft, Save } from "lucide-react"; +import { ArrowLeft, RotateCcw, Save } from "lucide-react"; import type { BookDto } from "@readabook/shared"; -import { api } from "../api/client"; -import { LoadingState, Meter } from "../components/ui"; +import { api, getApiFallback } from "../api/client"; +import { ErrorRibbon, Meter } from "../components/ui"; import { navigate } from "../router"; import { EpubReader } from "../reader/EpubReader"; +import { parsePdfPageLocator, pdfPagePercent } from "../reader/locators"; import { PdfReader } from "../reader/PdfReader"; import { useReaderProgress } from "../reader/useReaderProgress"; 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 { progress, saving, save } = useReaderProgress(bookId); + const { progress, saving, error: progressError, save } = useReaderProgress(bookId); + + async function loadBook() { + setLoading(true); + setError(undefined); + try { + setBook(await api.book(bookId)); + } catch (loadError) { + const fallback = getApiFallback(loadError); + setBook(fallback ?? null); + setError(fallback ? "Lecteur ouvert en mode secours." : "Ouvrage indisponible."); + } finally { + setLoading(false); + } + } useEffect(() => { - api.book(bookId).then(setBook); + void loadBook(); }, [bookId]); useEffect(() => { - if (progress?.locator.startsWith("pdf:page:")) setPage(Number(progress.locator.split(":").at(-1)) || 1); + const nextPage = parsePdfPageLocator(progress?.locator); + if (nextPage) setPage((current) => (current === nextPage ? current : nextPage)); }, [progress]); const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]); const savePdfPage = useCallback( (nextPage: number, pages: number) => { setPage(nextPage); - void save(`pdf:page:${nextPage}`, Math.round((nextPage / pages) * 100)); + void save(`pdf:page:${nextPage}`, pdfPagePercent(nextPage, pages)); }, [save] ); const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]); - if (!book) return ; - return (
-
- {book.title} - {saving ? "Sauvegarde" : "Progression synchronisee"} + {book?.title ?? "Ouverture du lecteur"} + {loading ? "Chargement" : saving ? "Sauvegarde" : "Progression synchronisee"}
- + {error ? ( + + ) : ( + + )}
+ - {book.format === "pdf" ? ( - + {!book ? ( +
+ {error ?? "Chargement du livre."} + +
+ ) : book.format === "pdf" ? ( + ) : ( )} diff --git a/apps/web/src/reader/EpubReader.tsx b/apps/web/src/reader/EpubReader.tsx index 8fba5d0..3b16cb1 100644 --- a/apps/web/src/reader/EpubReader.tsx +++ b/apps/web/src/reader/EpubReader.tsx @@ -7,6 +7,7 @@ type FoliateModule = { export function EpubReader({ url, locator, onLocatorChange }: { url: string; locator?: string; onLocatorChange: (locator: string, percent: number) => void }) { const hostRef = useRef(null); + const [frameKey, setFrameKey] = useState(0); const [status, setStatus] = useState("Ouverture EPUB"); useEffect(() => { @@ -17,7 +18,6 @@ export function EpubReader({ url, locator, onLocatorChange }: { url: string; loc if (cancelled || !hostRef.current) return; hostRef.current.dataset.engine = module.EPUB || module.default ? "foliate-js" : "fallback"; setStatus("EPUB pret"); - onLocatorChange(locator ?? "epub:start", locator ? 35 : 1); } catch { setStatus("Apercu EPUB indisponible dans ce navigateur"); } @@ -26,12 +26,20 @@ export function EpubReader({ url, locator, onLocatorChange }: { url: string; loc return () => { cancelled = true; }; - }, [locator, onLocatorChange, url]); + }, [url]); return (
-