feat(api,web): modélisation série + volume et parcours de série
- Table series + colonnes seriesId/volumeNumber/volumeLabel sur les livres, extraction souple des numéros (T03, 003, etc.) via extract-series-volume - Contrôleur et page de série, badge volume sur les cartes de livre, styles associés (fond/clarté série, ajustements globaux) - Statuts de scan/enrichissement et provenance des métadonnées en base Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -3,6 +3,7 @@ import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
|
||||
import type { BookDto, ProgressDto } from "@readabook/shared";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { cleanBookDescription } from "../book/description";
|
||||
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata";
|
||||
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
|
||||
@ -52,6 +53,18 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
const series = bookSeriesInfo(book);
|
||||
const metadataState = bookMetadataState(book);
|
||||
const publishedDate = displayPublishedDate(book.publishedDate);
|
||||
const detailFacts = [
|
||||
{ label: "Auteur", value: book.author },
|
||||
{ label: "Date", value: publishedDate },
|
||||
{ label: "Serie", value: series?.title },
|
||||
{ label: "Position", value: series?.volumeLabel },
|
||||
{ label: "Editeur", value: book.publisher },
|
||||
{ label: "ISBN", value: book.isbn13 ?? book.isbn }
|
||||
].filter((fact) => fact.value);
|
||||
|
||||
return (
|
||||
<div className="book-detail">
|
||||
<section className="book-portrait">
|
||||
@ -62,9 +75,18 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
<div className="book-card-meta">
|
||||
<FormatPill format={book.format} />
|
||||
<span>{book.language ?? "langue inconnue"}</span>
|
||||
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
|
||||
</div>
|
||||
<h1>{book.title}</h1>
|
||||
<h1>{bookDisplayTitle(book)}</h1>
|
||||
<p className="lead">{book.author ?? "Auteur inconnu"}</p>
|
||||
<dl className="book-fact-list">
|
||||
{detailFacts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="book-description">{cleanBookDescription(book.description)}</p>
|
||||
{progress && <Meter value={progress.percent} />}
|
||||
<div className="book-card-actions">
|
||||
@ -76,6 +98,12 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
<LibraryBig size={18} />
|
||||
Rayon
|
||||
</button>
|
||||
{series && (
|
||||
<button className="ghost-button" onClick={() => navigate(`/catalog/series/${encodeURIComponent(series.title)}`)}>
|
||||
<LibraryBig size={18} />
|
||||
Serie
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||
<RotateCcw size={18} />
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LibraryBig, ScanLine } from "lucide-react";
|
||||
import { BookOpen, LibraryBig, ScanLine } from "lucide-react";
|
||||
import { api } from "../api/client";
|
||||
import type { DashboardData } from "../api/types";
|
||||
import { hasActiveCoverWork, isBookCoverUpdating, type DashboardData } from "../api/types";
|
||||
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookVolumeLabel, displayPublishedDate } from "../book/metadata";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
@ -27,6 +28,7 @@ export function HomePage() {
|
||||
}, []);
|
||||
|
||||
if (!state) return <LoadingState />;
|
||||
const fallbackCoverLoading = hasActiveCoverWork(state.jobs);
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
@ -49,13 +51,25 @@ export function HomePage() {
|
||||
</div>
|
||||
{state.continueReading.length ? (
|
||||
<div className="continue-grid">
|
||||
{state.continueReading.map((item) => (
|
||||
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}>
|
||||
<strong>{item.book.title}</strong>
|
||||
<span>{item.book.author ?? "Auteur inconnu"}</span>
|
||||
<Meter value={item.progress.percent} />
|
||||
</button>
|
||||
))}
|
||||
{state.continueReading.map((item) => {
|
||||
const metadataState = bookMetadataState(item.book);
|
||||
const publishedDate = displayPublishedDate(item.book.publishedDate);
|
||||
const volumeLabel = bookVolumeLabel(item.book);
|
||||
return (
|
||||
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}>
|
||||
<span className="continue-cover" aria-hidden="true">
|
||||
{item.book.coverPath ? <img src={api.bookCoverUrl(item.book.id)} alt="" /> : <BookOpen size={22} />}
|
||||
</span>
|
||||
<span className="continue-copy">
|
||||
<strong>{bookDisplayTitle(item.book)}</strong>
|
||||
<span>{item.book.author ?? "Auteur inconnu"}</span>
|
||||
{(volumeLabel || publishedDate) && <span>{[volumeLabel, publishedDate].filter(Boolean).join(" · ")}</span>}
|
||||
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(item.book)}</span>
|
||||
<Meter value={item.progress.percent} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." />
|
||||
@ -79,7 +93,7 @@ export function HomePage() {
|
||||
|
||||
<section className="book-grid span-3">
|
||||
{state.books.map((book) => (
|
||||
<BookCard key={book.id} book={book} />
|
||||
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@ -1,19 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { BookDto, LibraryDto } from "@readabook/shared";
|
||||
import type { BookDto, JobDto, LibraryDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { hasActiveCoverWork, isBookCoverUpdating } from "../api/types";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
export function LibraryPage({ libraryId }: { libraryId: number }) {
|
||||
const [books, setBooks] = useState<BookDto[] | null>(null);
|
||||
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
|
||||
const [jobs, setJobs] = useState<JobDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
Promise.all([api.books({ libraryId }), api.libraries()]).then(([nextBooks, nextLibraries]) => {
|
||||
Promise.all([api.books({ libraryId }), api.libraries(), api.jobs().catch(() => [])]).then(([nextBooks, nextLibraries, nextJobs]) => {
|
||||
if (!alive) return;
|
||||
setBooks(nextBooks);
|
||||
setLibraries(nextLibraries);
|
||||
setJobs(nextJobs);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
@ -22,6 +25,7 @@ export function LibraryPage({ libraryId }: { libraryId: number }) {
|
||||
|
||||
if (!books) return <LoadingState />;
|
||||
const library = libraries.find((item) => item.id === libraryId);
|
||||
const fallbackCoverLoading = hasActiveCoverWork(jobs);
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
@ -37,7 +41,7 @@ export function LibraryPage({ libraryId }: { libraryId: number }) {
|
||||
{books.length ? (
|
||||
<section className="book-grid span-3">
|
||||
{books.map((book) => (
|
||||
<BookCard key={book.id} book={book} />
|
||||
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import type { BookDto, JobDto } from "@readabook/shared";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { hasActiveCoverWork, isBookCoverUpdating } from "../api/types";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
export function SearchPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [books, setBooks] = useState<BookDto[]>([]);
|
||||
const [jobs, setJobs] = useState<JobDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
@ -15,7 +17,9 @@ export function SearchPage() {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
setBooks(nextQuery.trim() ? await api.search(nextQuery.trim()) : await api.books());
|
||||
const [nextBooks, nextJobs] = await Promise.all([nextQuery.trim() ? api.search(nextQuery.trim()) : api.books(), api.jobs().catch(() => [])]);
|
||||
setBooks(nextBooks);
|
||||
setJobs(nextJobs);
|
||||
} catch (loadError) {
|
||||
const fallback = getApiFallback<BookDto[]>(loadError);
|
||||
setBooks(fallback ?? []);
|
||||
@ -25,6 +29,8 @@ export function SearchPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackCoverLoading = hasActiveCoverWork(jobs);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBooks("");
|
||||
}, []);
|
||||
@ -58,7 +64,7 @@ export function SearchPage() {
|
||||
) : books.length ? (
|
||||
<section className="book-grid span-3">
|
||||
{books.map((book) => (
|
||||
<BookCard key={book.id} book={book} />
|
||||
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
|
||||
53
apps/web/src/pages/SeriesPage.tsx
Normal file
53
apps/web/src/pages/SeriesPage.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { bookSeriesInfo } from "../book/metadata";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
export function SeriesPage({ seriesName }: { seriesName: string }) {
|
||||
const [books, setBooks] = useState<BookDto[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.books().then((nextBooks) => {
|
||||
if (alive) setBooks(nextBooks);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const seriesBooks = useMemo(() => {
|
||||
const expected = seriesName.trim().toLocaleLowerCase();
|
||||
return (books ?? [])
|
||||
.filter((book) => bookSeriesInfo(book)?.title.trim().toLocaleLowerCase() === expected)
|
||||
.sort((left, right) => (bookSeriesInfo(left)?.volumeNumber ?? Number.MAX_SAFE_INTEGER) - (bookSeriesInfo(right)?.volumeNumber ?? Number.MAX_SAFE_INTEGER));
|
||||
}, [books, seriesName]);
|
||||
|
||||
if (!books) return <LoadingState />;
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Panel className="span-3">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h1>{seriesName}</h1>
|
||||
<p>{seriesBooks.length} volumes reperes</p>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
{seriesBooks.length ? (
|
||||
<section className="book-grid span-3">
|
||||
{seriesBooks.map((book) => (
|
||||
<BookCard key={book.id} book={book} />
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<Panel className="span-3">
|
||||
<EmptyState title="Serie introuvable" detail="Les volumes apparaitront ici quand le catalogue exposera leur serie." />
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user