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:
Git Agent
2026-08-24 09:11:10 +02:00
parent 34159e5e9b
commit 93bb40a8cd
24 changed files with 1481 additions and 76 deletions

View File

@ -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>