merge: feature/46-home-grid-error-pages dans develop (grilles limitées à 5 colonnes, bandeau API retiré, pages d'erreur pour livres inexistants — évolution #46)

This commit is contained in:
Git Agent
2026-08-28 23:29:08 +02:00
6 changed files with 106 additions and 34 deletions

View File

@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("book missing error pages", () => {
it("does not render fallback books on the book detail page", () => {
const source = readFileSync(new URL("./BookPage.tsx", import.meta.url), "utf8");
expect(source).not.toContain("getApiFallback");
expect(source).toContain("setBook(null)");
expect(source).toContain("Livre introuvable");
expect(source).toContain("Ce livre n'existe pas dans le catalogue.");
});
it("does not open the reader with a fallback book", () => {
const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8");
expect(source).not.toContain("getApiFallback");
expect(source).toContain("setBook(null)");
expect(source).toContain("reader-missing-page");
expect(source).toContain("Livre introuvable");
expect(source).toContain("Ce livre n'existe pas dans le catalogue.");
});
});

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { BookOpen, LibraryBig, RotateCcw } from "lucide-react"; import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
import type { BookDto, ProgressDto } from "@readabook/shared"; import type { BookDto, ProgressDto } from "@readabook/shared";
import { api, getApiFallback } from "../api/client"; import { api } from "../api/client";
import { cleanBookDescription } from "../book/description"; import { cleanBookDescription } from "../book/description";
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata"; import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata";
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui"; import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
@ -17,14 +17,17 @@ export function BookPage({ bookId }: { bookId: number }) {
setLoading(true); setLoading(true);
setError(undefined); setError(undefined);
try { try {
const [nextBook, nextProgress] = await Promise.all([api.book(bookId), api.progress(bookId)]); const nextBook = await api.book(bookId);
setBook(nextBook); setBook(nextBook);
setProgress(nextProgress); try {
} catch (loadError) { setProgress(await api.progress(bookId));
const fallback = getApiFallback<BookDto>(loadError); } catch {
setBook(fallback ?? null); setProgress(null);
}
} catch {
setBook(null);
setProgress(null); setProgress(null);
setError(fallback ? "Fiche chargee en mode secours." : "Fiche livre indisponible."); setError("Ce livre n'existe pas dans le catalogue.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -43,13 +46,20 @@ export function BookPage({ bookId }: { bookId: number }) {
if (loading && !book) return <LoadingState />; if (loading && !book) return <LoadingState />;
if (!book) { if (!book) {
return ( return (
<Panel> <section className="book-error-page" role="alert">
<EmptyState title="Fiche introuvable" detail={error ?? "Le catalogue n'a pas renvoye cet ouvrage."} /> <Panel>
<button className="ghost-button" onClick={() => void loadBook()}> <EmptyState title="Livre introuvable" detail={error ?? "Le serveur n'a pas renvoye cet ouvrage."} />
<RotateCcw size={17} /> <div className="book-card-actions">
Reessayer <button className="ghost-button" onClick={() => navigate("/home")}>
</button> Retour à l'accueil
</Panel> </button>
<button className="ghost-button" onClick={() => void loadBook()}>
<RotateCcw size={17} />
Reessayer
</button>
</div>
</Panel>
</section>
); );
} }

View File

@ -8,14 +8,12 @@ import { navigate } from "../router";
export function HomePage() { export function HomePage() {
const [state, setState] = useState<DashboardData | null>(null); const [state, setState] = useState<DashboardData | null>(null);
const [fallback, setFallback] = useState(false);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
Promise.all([api.books(), api.continueReading(), api.libraries(), api.jobs()]) Promise.all([api.books(), api.continueReading(), api.libraries(), api.jobs()])
.then(([books, continueReading, libraries, jobs]) => { .then(([books, continueReading, libraries, jobs]) => {
if (!alive) return; if (!alive) return;
setFallback(books.some((book) => book.filePath.startsWith("/library/")) && jobs.length === 1);
setState({ books, continueReading, libraries, jobs }); setState({ books, continueReading, libraries, jobs });
}) })
.catch(() => { .catch(() => {
@ -50,7 +48,7 @@ export function HomePage() {
<div> <div>
<p>Bibliotheque personnelle</p> <p>Bibliotheque personnelle</p>
<h1>Reprendre la lecture.</h1> <h1>Reprendre la lecture.</h1>
<span>{fallback ? "API absente ou incomplete : specimens de demonstration actifs." : "Catalogue branche sur le serveur local."}</span> <span>Catalogue branche sur le serveur local.</span>
</div> </div>
<button className="primary-button" onClick={() => navigate("/search")}> <button className="primary-button" onClick={() => navigate("/search")}>
<ScanLine size={18} /> <ScanLine size={18} />

View File

@ -28,5 +28,16 @@ describe("home progress list layout", () => {
expect(source).not.toContain("displayPublishedDate"); expect(source).not.toContain("displayPublishedDate");
expect(source).not.toContain("book.description"); expect(source).not.toContain("book.description");
expect(source).not.toContain("book.language"); expect(source).not.toContain("book.language");
expect(source).not.toContain("API absente ou incomplete");
expect(source).not.toContain("specimens de demonstration actifs");
});
it("keeps visible book grids capped to five columns", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
const bookGrid = styles.match(/\.book-grid\s*\{[^}]+\}/)?.[0] ?? "";
const homeBookGrid = styles.match(/\.home-book-grid\s*\{[^}]+\}/)?.[0] ?? "";
expect(bookGrid).toContain("max-width: calc((var(--book-grid-column-min) * 5) + (var(--book-grid-gap) * 4))");
expect(homeBookGrid).toContain("max-width: calc((var(--book-grid-column-min) * 5) + (var(--book-grid-gap) * 4))");
}); });
}); });

View File

@ -1,6 +1,6 @@
import { Component, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from "react"; import { Component, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from "react";
import type { BookDto } from "@readabook/shared"; import type { BookDto } from "@readabook/shared";
import { api, getApiFallback } from "../api/client"; import { api } from "../api/client";
import { CbzReader } from "../reader/CbzReader"; import { CbzReader } from "../reader/CbzReader";
import { EpubReader } from "../reader/EpubReader"; import { EpubReader } from "../reader/EpubReader";
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators"; import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
@ -11,6 +11,7 @@ import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
import { useReaderPreferences } from "../reader/useReaderPreferences"; import { useReaderPreferences } from "../reader/useReaderPreferences";
import { useReaderProgress } from "../reader/useReaderProgress"; import { useReaderProgress } from "../reader/useReaderProgress";
import { navigate } from "../router"; import { navigate } from "../router";
import { EmptyState, LoadingState, Panel } from "../components/ui";
const idleControls: ReaderControls = { const idleControls: ReaderControls = {
canPrevious: false, canPrevious: false,
@ -74,10 +75,9 @@ export function ReaderPage({ bookId }: { bookId: number }) {
setError(undefined); setError(undefined);
try { try {
setBook(await api.book(bookId)); setBook(await api.book(bookId));
} catch (loadError) { } catch {
const fallback = getApiFallback<BookDto>(loadError); setBook(null);
setBook(fallback ?? null); setError("Ce livre n'existe pas dans le catalogue.");
setError(fallback ? "Lecteur ouvert en mode secours." : "Ouvrage indisponible.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -206,6 +206,26 @@ export function ReaderPage({ bookId }: { bookId: number }) {
[changeMode, mode, supportsMode] [changeMode, mode, supportsMode]
); );
if (loading && !book) return <LoadingState />;
if (!book) {
return (
<section className="reader-missing-page" role="alert">
<Panel>
<EmptyState title="Livre introuvable" detail={error ?? "Le serveur n'a pas renvoye cet ouvrage."} />
<div className="reader-error-actions">
<button className="ghost-button" onClick={() => navigate("/home")}>
Retour à l'accueil
</button>
<button className="ghost-button" onClick={() => void loadBook()}>
Réessayer
</button>
</div>
</Panel>
</section>
);
}
return ( return (
<ReaderShell <ReaderShell
title={book?.title ?? "Ouverture du lecteur"} title={book?.title ?? "Ouverture du lecteur"}
@ -251,14 +271,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
</div> </div>
)} )}
> >
{!book ? ( {book.format === "pdf" ? (
<div className="reader-fallback">
<span>{error ?? "Chargement du livre."}</span>
<button className="ghost-button" onClick={() => void loadBook()}>
Reessayer
</button>
</div>
) : book.format === "pdf" ? (
<PdfReader <PdfReader
url={fileUrl} url={fileUrl}
page={page} page={page}

View File

@ -161,9 +161,13 @@ h2 {
} }
.book-grid { .book-grid {
--book-grid-column-min: 240px;
--book-grid-gap: 16px;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(var(--book-grid-column-min), 1fr));
gap: 16px; gap: var(--book-grid-gap);
width: 100%;
max-width: calc((var(--book-grid-column-min) * 5) + (var(--book-grid-gap) * 4));
} }
.book-card { .book-card {
@ -237,6 +241,15 @@ h2 {
white-space: pre-line; white-space: pre-line;
} }
.book-error-page,
.reader-missing-page {
display: grid;
align-items: center;
width: min(100%, 680px);
min-height: 60vh;
margin: 0 auto;
}
.book-card-description { .book-card-description {
display: -webkit-box; display: -webkit-box;
overflow: hidden; overflow: hidden;
@ -439,9 +452,13 @@ h2 {
} }
.home-book-grid { .home-book-grid {
--book-grid-column-min: 190px;
--book-grid-gap: 14px;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(var(--book-grid-column-min), 1fr));
gap: 14px; gap: var(--book-grid-gap);
width: 100%;
max-width: calc((var(--book-grid-column-min) * 5) + (var(--book-grid-gap) * 4));
} }
.home-book-card { .home-book-card {