feat(books): marquage des livres comme lus — statut lu/non lu par utilisateur sur progress (colonnes read_status/completed_at/read_status_source, migration idempotente avec backfill à 95 %), endpoint PUT /progress/:bookId/status, complétion automatique au fil de la progression, état lu exposé sur catalogue/recherche/fiche/séries, étagère « continuer » limitée aux lectures en cours, badge Lu sur cartes/fiche/accueil et bouton Marquer comme lu/non lu sur la fiche, avec tests api/shared/web — évolution #52

This commit is contained in:
Git Agent
2026-08-30 10:37:42 +02:00
parent 3faa7ca44b
commit 05f5f1cd16
20 changed files with 664 additions and 43 deletions

View File

@ -14,6 +14,7 @@ import type {
UpdateAccountDto,
UpdateMetadataSourcesConfigDto,
UpdateProgressDto,
UpdateReadStatusDto,
UserDto
} from "@readabook/shared";
import {
@ -198,7 +199,30 @@ export const api = {
return request<ProgressDto>(`/progress/${bookId}`, {
method: "PUT",
body: JSON.stringify(input),
fallback: { bookId, ...input, updatedAt: new Date().toISOString() }
fallback: {
bookId,
...input,
readStatus: input.percent >= 95 ? "read" : input.percent > 0 ? "reading" : "unread",
completedAt: input.percent >= 95 ? new Date().toISOString() : null,
readStatusSource: "progress",
updatedAt: new Date().toISOString()
}
});
},
async markReadStatus(bookId: number, input: UpdateReadStatusDto): Promise<ProgressDto> {
const updatedAt = new Date().toISOString();
return request<ProgressDto>(`/progress/${bookId}/status`, {
method: "PUT",
body: JSON.stringify(input),
fallback: {
bookId,
locator: input.status === "read" ? "manual:read" : "manual:unread",
percent: input.status === "read" ? 100 : 0,
readStatus: input.status,
completedAt: input.status === "read" ? updatedAt : null,
readStatusSource: "manual",
updatedAt
}
});
},
async readerPreferences(bookId: number): Promise<ReaderPreferencesDto> {

View File

@ -3,7 +3,7 @@ import type { BookDto } from "@readabook/shared";
import { api } from "../api/client";
import { bookCardVolumeLabel, bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, displayPublishedDate } from "../book/metadata";
import { navigate } from "../router";
import { FormatPill } from "./ui";
import { FormatPill, ReadStatusBadge } from "./ui";
export function BookCard({ book, compact = false, coverLoading = false }: { book: BookDto; compact?: boolean; coverLoading?: boolean }) {
const metadataState = bookMetadataState(book);
@ -19,6 +19,7 @@ export function BookCard({ book, compact = false, coverLoading = false }: { book
<div className="book-card-body">
<div className="book-card-meta">
<FormatPill format={book.format} />
<ReadStatusBadge readStatus={book.readStatus} />
{volumeLabel && <span className="volume-pill">{volumeLabel}</span>}
<span className="book-card-language">{book.language ?? "langue inconnue"}</span>
</div>

View File

@ -1,4 +1,6 @@
import type { ReactNode } from "react";
import { CheckCircle2 } from "lucide-react";
import type { ReadStatus } from "@readabook/shared";
export function Panel({ children, className = "" }: { children: ReactNode; className?: string }) {
return <section className={`panel ${className}`}>{children}</section>;
@ -32,6 +34,16 @@ export function FormatPill({ format }: { format: "epub" | "pdf" | "cbz" | "cbr"
return <span className={`format-pill format-${format}`}>{format.toUpperCase()}</span>;
}
export function ReadStatusBadge({ readStatus }: { readStatus?: ReadStatus }) {
if (readStatus !== "read") return null;
return (
<span className="read-status-badge" aria-label="Livre lu">
<CheckCircle2 size={13} />
Lu
</span>
);
}
export function Meter({ value }: { value: number }) {
return (
<span className="meter" aria-label={`${Math.round(value)}%`}>

View File

@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
import { BookOpen, CheckCircle2, LibraryBig, RotateCcw } from "lucide-react";
import type { BookDto, ProgressDto } from "@readabook/shared";
import { api } 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 { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel, ReadStatusBadge } from "../components/ui";
import { navigate } from "../router";
export function BookPage({ bookId }: { bookId: number }) {
@ -33,6 +33,27 @@ export function BookPage({ bookId }: { bookId: number }) {
}
}
async function markReadStatus(status: "read" | "unread") {
if (!book) return;
setError(undefined);
try {
const nextProgress = await api.markReadStatus(book.id, { status });
setProgress(nextProgress);
setBook((current) =>
current
? {
...current,
readStatus: nextProgress.readStatus,
completedAt: nextProgress.completedAt,
readStatusSource: nextProgress.readStatusSource
}
: current
);
} catch {
setError(status === "read" ? "Marquage comme lu impossible." : "Marquage comme non lu impossible.");
}
}
useEffect(() => {
let alive = true;
loadBook().finally(() => {
@ -65,6 +86,7 @@ export function BookPage({ bookId }: { bookId: number }) {
const series = bookSeriesInfo(book);
const metadataState = bookMetadataState(book);
const readStatus = progress?.readStatus ?? book.readStatus;
const publishedDate = displayPublishedDate(book.publishedDate);
const detailFacts = [
{ label: "Auteur", value: book.author },
@ -84,6 +106,7 @@ export function BookPage({ bookId }: { bookId: number }) {
<ErrorRibbon message={error} />
<div className="book-card-meta">
<FormatPill format={book.format} />
<ReadStatusBadge readStatus={readStatus} />
<span>{book.language ?? "langue inconnue"}</span>
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
</div>
@ -108,6 +131,10 @@ export function BookPage({ bookId }: { bookId: number }) {
<LibraryBig size={18} />
Rayon
</button>
<button className="ghost-button read-status-action" onClick={() => void markReadStatus(readStatus === "read" ? "unread" : "read")}>
{readStatus === "read" ? <RotateCcw size={18} /> : <CheckCircle2 size={18} />}
{readStatus === "read" ? "Marquer comme non lu" : "Marquer comme lu"}
</button>
{series && (
<button className="ghost-button" onClick={() => navigate(`/catalog/series/${encodeURIComponent(series.title)}`)}>
<LibraryBig size={18} />

View File

@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("book read status frontend wiring", () => {
it("uses the backend read status endpoint for manual book detail actions", () => {
const client = readFileSync(new URL("../api/client.ts", import.meta.url), "utf8");
const bookPage = readFileSync(new URL("./BookPage.tsx", import.meta.url), "utf8");
expect(client).toContain("markReadStatus");
expect(client).toContain("`/progress/${bookId}/status`");
expect(client).toContain("body: JSON.stringify(input)");
expect(bookPage).toContain('api.markReadStatus(book.id, { status })');
expect(bookPage).toContain("Marquer comme lu");
expect(bookPage).toContain("Marquer comme non lu");
expect(bookPage).toContain("setProgress(nextProgress)");
expect(bookPage).toContain("readStatus: nextProgress.readStatus");
});
it("renders read badges on catalogue cards, home cards and the book detail page", () => {
const ui = readFileSync(new URL("../components/ui.tsx", import.meta.url), "utf8");
const bookCard = readFileSync(new URL("../components/BookCard.tsx", import.meta.url), "utf8");
const homePage = readFileSync(new URL("./HomePage.tsx", import.meta.url), "utf8");
const bookPage = readFileSync(new URL("./BookPage.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
expect(ui).toContain('readStatus !== "read"');
expect(ui).toContain('aria-label="Livre lu"');
expect(bookCard).toContain("<ReadStatusBadge readStatus={book.readStatus} />");
expect(homePage).toContain("<ReadStatusBadge readStatus={item.readStatus} />");
expect(bookPage).toContain("<ReadStatusBadge readStatus={readStatus} />");
expect(styles).toContain(".read-status-badge");
});
it("keeps continue reading delegated to the API-updated reading list", () => {
const homePage = readFileSync(new URL("./HomePage.tsx", import.meta.url), "utf8");
expect(homePage).toContain("api.continueReading()");
expect(homePage).not.toContain('readStatus !== "read"');
expect(homePage).not.toContain('readStatus === "read"');
});
});

View File

@ -3,7 +3,7 @@ import { BookOpen, LibraryBig, ScanLine } from "lucide-react";
import { api } from "../api/client";
import type { DashboardData } from "../api/types";
import { bookDisplayTitle, bookVolumeLabel } from "../book/metadata";
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
import { EmptyState, LoadingState, Meter, Panel, ReadStatusBadge } from "../components/ui";
import { navigate } from "../router";
export function HomePage() {
@ -39,6 +39,7 @@ export function HomePage() {
</span>
<span className="home-book-copy">
{volumeLabel && <span className="home-book-volume">{volumeLabel}</span>}
<ReadStatusBadge readStatus={item.readStatus} />
<strong>{bookDisplayTitle(item)}</strong>
<span>{item.author ?? "Auteur inconnu"}</span>
{options.progressPercent !== undefined && <Meter value={options.progressPercent} />}

View File

@ -293,6 +293,11 @@ h2 {
white-space: nowrap;
}
.book-card-actions .read-status-action {
flex-basis: 180px;
white-space: normal;
}
.format-pill {
flex: 0 0 auto;
padding: 4px 7px;
@ -339,6 +344,22 @@ h2 {
color: var(--ink);
}
.read-status-badge {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
width: max-content;
max-width: 100%;
padding: 4px 7px;
border: 1px solid rgba(45, 111, 99, 0.76);
border-radius: 999px;
color: #d8fff5;
font-size: 0.72rem;
font-weight: 900;
background: rgba(45, 111, 99, 0.2);
}
.metadata-pill {
width: max-content;
max-width: 100%;