- 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
89 lines
2.9 KiB
TypeScript
89 lines
2.9 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
|
|
import type { BookDto, ProgressDto } from "@readabook/shared";
|
|
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;
|
|
loadBook().finally(() => {
|
|
if (!alive) return;
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [bookId]);
|
|
|
|
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">
|
|
<section className="book-portrait">
|
|
{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>
|
|
</div>
|
|
<h1>{book.title}</h1>
|
|
<p className="lead">{book.author ?? "Auteur inconnu"}</p>
|
|
<p>{book.description ?? "Notice absente du catalogue."}</p>
|
|
{progress && <Meter value={progress.percent} />}
|
|
<div className="book-card-actions">
|
|
<button className="primary-button" onClick={() => navigate(`/reader/${book.id}`)}>
|
|
<BookOpen size={18} />
|
|
Lire
|
|
</button>
|
|
<button className="ghost-button" onClick={() => navigate(`/library/${book.libraryId}`)}>
|
|
<LibraryBig size={18} />
|
|
Rayon
|
|
</button>
|
|
{error && (
|
|
<button className="ghost-button" onClick={() => void loadBook()}>
|
|
<RotateCcw size={18} />
|
|
Reessayer
|
|
</button>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
</div>
|
|
);
|
|
}
|