feat: suivi de la progression de lecture par livre

- API : nouvelle table book_reading_progress (migration 0016) et module
  reading_progress.rs avec GET/PATCH /books/:id/progress (token read)
- API : GET /books/:id enrichi avec reading_status, reading_current_page,
  reading_last_read_at via LEFT JOIN
- Backoffice : badge de statut (Non lu / En cours · p.N / Lu) sur la page
  de détail et overlay sur les BookCards
- OpenSpec : change reading-progress avec proposal/design/specs/tasks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 21:53:52 +01:00
parent 278f422206
commit 648d86970f
16 changed files with 516 additions and 11 deletions

View File

@@ -1,4 +1,4 @@
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch } from "../../../lib/api";
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "../../../lib/api";
import { BookPreview } from "../../components/BookPreview";
import { ConvertButton } from "../../components/ConvertButton";
import Image from "next/image";
@@ -7,6 +7,37 @@ import { notFound } from "next/navigation";
export const dynamic = "force-dynamic";
const readingStatusConfig: Record<ReadingStatus, { label: string; className: string }> = {
unread: { label: "Non lu", className: "bg-muted/60 text-muted-foreground border border-border" },
reading: { label: "En cours", className: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30" },
read: { label: "Lu", className: "bg-green-500/15 text-green-600 dark:text-green-400 border border-green-500/30" },
};
function ReadingStatusBadge({
status,
currentPage,
lastReadAt,
}: {
status: ReadingStatus;
currentPage: number | null;
lastReadAt: string | null;
}) {
const { label, className } = readingStatusConfig[status];
return (
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${className}`}>
{label}
{status === "reading" && currentPage != null && ` · p. ${currentPage}`}
</span>
{lastReadAt && (
<span className="text-xs text-muted-foreground">
{new Date(lastReadAt).toLocaleDateString()}
</span>
)}
</div>
);
}
async function fetchBook(bookId: string): Promise<BookDto | null> {
try {
return await apiFetch<BookDto>(`/books/${bookId}`);
@@ -71,6 +102,17 @@ export default async function BookDetailPage({
)}
<div className="space-y-3">
{book.reading_status && (
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Lecture :</span>
<ReadingStatusBadge
status={book.reading_status}
currentPage={book.reading_current_page ?? null}
lastReadAt={book.reading_last_read_at ?? null}
/>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">Format:</span>
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${

View File

@@ -3,10 +3,17 @@
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { BookDto } from "../../lib/api";
import { BookDto, ReadingStatus } from "../../lib/api";
const readingStatusOverlay: Record<ReadingStatus, { label: string; className: string } | null> = {
unread: null,
reading: { label: "En cours", className: "bg-amber-500/90 text-white" },
read: { label: "Lu", className: "bg-green-600/90 text-white" },
};
interface BookCardProps {
book: BookDto & { coverUrl?: string };
readingStatus?: ReadingStatus;
}
function BookImage({ src, alt }: { src: string; alt: string }) {
@@ -37,18 +44,27 @@ function BookImage({ src, alt }: { src: string; alt: string }) {
);
}
export function BookCard({ book }: BookCardProps) {
export function BookCard({ book, readingStatus }: BookCardProps) {
const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`;
const status = readingStatus ?? book.reading_status;
const overlay = status ? readingStatusOverlay[status] : null;
return (
<Link
href={`/books/${book.id}`}
<Link
href={`/books/${book.id}`}
className="group block bg-card rounded-xl border border-border/60 shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-200 overflow-hidden"
>
<BookImage
src={coverUrl}
alt={`Cover of ${book.title}`}
/>
<div className="relative">
<BookImage
src={coverUrl}
alt={`Cover of ${book.title}`}
/>
{overlay && (
<span className={`absolute bottom-2 left-2 px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wide ${overlay.className}`}>
{overlay.label}
</span>
)}
</div>
{/* Book Info */}
<div className="p-4">

View File

@@ -248,6 +248,29 @@ body::after {
overflow: hidden;
}
/* Reading progress badge variants */
.badge-unread {
background: hsl(var(--color-muted) / 0.6);
color: hsl(var(--color-muted-foreground));
border-color: hsl(var(--color-border));
}
.badge-in-progress {
background: hsl(38 92% 50% / 0.15);
color: hsl(38 92% 40%);
border-color: hsl(38 92% 50% / 0.3);
}
.dark .badge-in-progress {
color: hsl(38 92% 65%);
}
.badge-completed {
background: hsl(var(--color-success) / 0.15);
color: hsl(var(--color-success));
border-color: hsl(var(--color-success) / 0.3);
}
/* Hide scrollbar */
.scrollbar-hide {
-ms-overflow-style: none;