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:
23
apps/web/src/pages/BookErrorPages.test.ts
Normal file
23
apps/web/src/pages/BookErrorPages.test.ts
Normal 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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
setProgress(null);
|
||||||
setError(fallback ? "Fiche chargee en mode secours." : "Fiche livre indisponible.");
|
}
|
||||||
|
} catch {
|
||||||
|
setBook(null);
|
||||||
|
setProgress(null);
|
||||||
|
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 (
|
||||||
|
<section className="book-error-page" role="alert">
|
||||||
<Panel>
|
<Panel>
|
||||||
<EmptyState title="Fiche introuvable" detail={error ?? "Le catalogue n'a pas renvoye cet ouvrage."} />
|
<EmptyState title="Livre introuvable" detail={error ?? "Le serveur n'a pas renvoye cet ouvrage."} />
|
||||||
|
<div className="book-card-actions">
|
||||||
|
<button className="ghost-button" onClick={() => navigate("/home")}>
|
||||||
|
Retour à l'accueil
|
||||||
|
</button>
|
||||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||||
<RotateCcw size={17} />
|
<RotateCcw size={17} />
|
||||||
Reessayer
|
Reessayer
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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))");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user