fix(web,api): UI fiche livre et lecteur — progression, locators EPUB/PDF, styles
- 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
This commit is contained in:
@ -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<BookDto | null>(null);
|
||||
const [progress, setProgress] = useState<ProgressDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
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<BookDto>(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 <LoadingState />;
|
||||
if (loading && !book) return <LoadingState />;
|
||||
if (!book) {
|
||||
return (
|
||||
<Panel>
|
||||
<EmptyState title="Fiche introuvable" detail={error ?? "Le catalogue n'a pas renvoye cet ouvrage."} />
|
||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||
<RotateCcw size={17} />
|
||||
Reessayer
|
||||
</button>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="book-detail">
|
||||
@ -29,6 +57,7 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
{book.coverPath ? <img src={api.bookCoverUrl(book.id)} alt="" /> : <BookOpen size={72} />}
|
||||
</section>
|
||||
<Panel className="book-facts">
|
||||
<ErrorRibbon message={error} />
|
||||
<div className="book-card-meta">
|
||||
<FormatPill format={book.format} />
|
||||
<span>{book.language ?? "langue inconnue"}</span>
|
||||
@ -46,6 +75,12 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
<LibraryBig size={18} />
|
||||
Rayon
|
||||
</button>
|
||||
{error && (
|
||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||
<RotateCcw size={18} />
|
||||
Reessayer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
@ -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<BookDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
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<BookDto>(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 <LoadingState label="Ouverture du lecteur" />;
|
||||
|
||||
return (
|
||||
<div className="reader-page">
|
||||
<header className="reader-topbar">
|
||||
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}>
|
||||
<button className="ghost-button" onClick={() => navigate(book ? `/book/${book.id}` : "/home")}>
|
||||
<ArrowLeft size={17} />
|
||||
Fiche
|
||||
</button>
|
||||
<div>
|
||||
<strong>{book.title}</strong>
|
||||
<span>{saving ? "Sauvegarde" : "Progression synchronisee"}</span>
|
||||
<strong>{book?.title ?? "Ouverture du lecteur"}</strong>
|
||||
<span>{loading ? "Chargement" : saving ? "Sauvegarde" : "Progression synchronisee"}</span>
|
||||
</div>
|
||||
<Save size={18} />
|
||||
{error ? (
|
||||
<button className="ghost-button icon-only" onClick={() => void loadBook()} aria-label="Reessayer">
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<Save size={18} />
|
||||
)}
|
||||
</header>
|
||||
<ErrorRibbon message={error ?? progressError} />
|
||||
<Meter value={progress?.percent ?? 0} />
|
||||
{book.format === "pdf" ? (
|
||||
<PdfReader url={fileUrl} page={page} onPageChange={savePdfPage} />
|
||||
{!book ? (
|
||||
<div className="reader-fallback">
|
||||
<span>{error ?? "Chargement du livre."}</span>
|
||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||
Reessayer
|
||||
</button>
|
||||
</div>
|
||||
) : book.format === "pdf" ? (
|
||||
<PdfReader url={fileUrl} page={page} onPageCommit={savePdfPage} />
|
||||
) : (
|
||||
<EpubReader url={fileUrl} locator={progress?.locator} onLocatorChange={saveEpubLocator} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user