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:
@ -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> {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}%`}>
|
||||
|
||||
@ -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} />
|
||||
|
||||
41
apps/web/src/pages/BookReadStatus.test.ts
Normal file
41
apps/web/src/pages/BookReadStatus.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
@ -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} />}
|
||||
|
||||
@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user