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 (
-