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:
@@ -63,6 +63,11 @@ pub struct BookDetails {
|
||||
pub file_path: Option<String>,
|
||||
pub file_format: Option<String>,
|
||||
pub file_parse_status: Option<String>,
|
||||
/// Reading status: "unread", "reading", or "read"
|
||||
pub reading_status: String,
|
||||
pub reading_current_page: Option<i32>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub reading_last_read_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// List books with optional filtering and pagination
|
||||
@@ -189,7 +194,10 @@ pub async fn get_book(
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.series, b.volume, b.language, b.page_count, b.thumbnail_path,
|
||||
bf.abs_path, bf.format, bf.parse_status
|
||||
bf.abs_path, bf.format, bf.parse_status,
|
||||
COALESCE(brp.status, 'unread') AS reading_status,
|
||||
brp.current_page AS reading_current_page,
|
||||
brp.last_read_at AS reading_last_read_at
|
||||
FROM books b
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT abs_path, format, parse_status
|
||||
@@ -198,6 +206,7 @@ pub async fn get_book(
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
) bf ON TRUE
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
WHERE b.id = $1
|
||||
"#,
|
||||
)
|
||||
@@ -221,6 +230,9 @@ pub async fn get_book(
|
||||
file_path: row.get("abs_path"),
|
||||
file_format: row.get("format"),
|
||||
file_parse_status: row.get("parse_status"),
|
||||
reading_status: row.get("reading_status"),
|
||||
reading_current_page: row.get("reading_current_page"),
|
||||
reading_last_read_at: row.get("reading_last_read_at"),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,13 @@ impl ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unprocessable_entity(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::UNPROCESSABLE_ENTITY,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
|
||||
@@ -7,6 +7,7 @@ mod libraries;
|
||||
mod api_middleware;
|
||||
mod openapi;
|
||||
mod pages;
|
||||
mod reading_progress;
|
||||
mod search;
|
||||
mod settings;
|
||||
mod state;
|
||||
@@ -106,6 +107,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/books/:id", get(books::get_book))
|
||||
.route("/books/:id/thumbnail", get(books::get_thumbnail))
|
||||
.route("/books/:id/pages/:n", get(pages::get_page))
|
||||
.route("/books/:id/progress", get(reading_progress::get_reading_progress).patch(reading_progress::update_reading_progress))
|
||||
.route("/libraries/:library_id/series", get(books::list_series))
|
||||
.route("/search", get(search::search_books))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))
|
||||
|
||||
@@ -6,6 +6,8 @@ use utoipa::OpenApi;
|
||||
paths(
|
||||
crate::books::list_books,
|
||||
crate::books::get_book,
|
||||
crate::reading_progress::get_reading_progress,
|
||||
crate::reading_progress::update_reading_progress,
|
||||
crate::books::get_thumbnail,
|
||||
crate::books::list_series,
|
||||
crate::books::convert_book,
|
||||
@@ -42,6 +44,8 @@ use utoipa::OpenApi;
|
||||
crate::books::BookItem,
|
||||
crate::books::BooksPage,
|
||||
crate::books::BookDetails,
|
||||
crate::reading_progress::ReadingProgressResponse,
|
||||
crate::reading_progress::UpdateReadingProgressRequest,
|
||||
crate::books::SeriesItem,
|
||||
crate::books::SeriesPage,
|
||||
crate::pages::PageQuery,
|
||||
@@ -72,6 +76,7 @@ use utoipa::OpenApi;
|
||||
),
|
||||
tags(
|
||||
(name = "books", description = "Read-only endpoints for browsing and searching books"),
|
||||
(name = "reading-progress", description = "Reading progress tracking per book"),
|
||||
(name = "libraries", description = "Library management endpoints (Admin only)"),
|
||||
(name = "indexing", description = "Search index management and job control (Admin only)"),
|
||||
(name = "tokens", description = "API token management (Admin only)"),
|
||||
|
||||
167
apps/api/src/reading_progress.rs
Normal file
167
apps/api/src/reading_progress.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use axum::{extract::{Path, State}, Json};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ReadingProgressResponse {
|
||||
/// Reading status: "unread", "reading", or "read"
|
||||
pub status: String,
|
||||
/// Current page (only set when status is "reading")
|
||||
pub current_page: Option<i32>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub last_read_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct UpdateReadingProgressRequest {
|
||||
/// Reading status: "unread", "reading", or "read"
|
||||
pub status: String,
|
||||
/// Required when status is "reading", must be > 0
|
||||
pub current_page: Option<i32>,
|
||||
}
|
||||
|
||||
/// Get reading progress for a book
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/books/{id}/progress",
|
||||
tag = "reading-progress",
|
||||
params(
|
||||
("id" = String, Path, description = "Book UUID"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = ReadingProgressResponse),
|
||||
(status = 404, description = "Book not found"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn get_reading_progress(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
||||
// Verify book exists
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)")
|
||||
.bind(id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
if !exists {
|
||||
return Err(ApiError::not_found("book not found"));
|
||||
}
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let response = match row {
|
||||
Some(r) => ReadingProgressResponse {
|
||||
status: r.get("status"),
|
||||
current_page: r.get("current_page"),
|
||||
last_read_at: r.get("last_read_at"),
|
||||
},
|
||||
None => ReadingProgressResponse {
|
||||
status: "unread".to_string(),
|
||||
current_page: None,
|
||||
last_read_at: None,
|
||||
},
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// Update reading progress for a book
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/books/{id}/progress",
|
||||
tag = "reading-progress",
|
||||
params(
|
||||
("id" = String, Path, description = "Book UUID"),
|
||||
),
|
||||
request_body = UpdateReadingProgressRequest,
|
||||
responses(
|
||||
(status = 200, body = ReadingProgressResponse),
|
||||
(status = 404, description = "Book not found"),
|
||||
(status = 422, description = "Validation error (missing or invalid current_page for status 'reading')"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn update_reading_progress(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<UpdateReadingProgressRequest>,
|
||||
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
||||
// Validate status value
|
||||
if !["unread", "reading", "read"].contains(&body.status.as_str()) {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"invalid status '{}': must be one of unread, reading, read",
|
||||
body.status
|
||||
)));
|
||||
}
|
||||
|
||||
// Validate current_page for "reading" status
|
||||
if body.status == "reading" {
|
||||
match body.current_page {
|
||||
None => {
|
||||
return Err(ApiError::unprocessable_entity(
|
||||
"current_page is required when status is 'reading'",
|
||||
))
|
||||
}
|
||||
Some(p) if p <= 0 => {
|
||||
return Err(ApiError::unprocessable_entity(
|
||||
"current_page must be greater than 0",
|
||||
))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify book exists
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)")
|
||||
.bind(id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
if !exists {
|
||||
return Err(ApiError::not_found("book not found"));
|
||||
}
|
||||
|
||||
// current_page is only stored for "reading" status
|
||||
let current_page = if body.status == "reading" {
|
||||
body.current_page
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (book_id) DO UPDATE
|
||||
SET status = EXCLUDED.status,
|
||||
current_page = EXCLUDED.current_page,
|
||||
last_read_at = NOW(),
|
||||
updated_at = NOW()
|
||||
RETURNING status, current_page, last_read_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&body.status)
|
||||
.bind(current_page)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ReadingProgressResponse {
|
||||
status: row.get("status"),
|
||||
current_page: row.get("current_page"),
|
||||
last_read_at: row.get("last_read_at"),
|
||||
}))
|
||||
}
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -46,6 +46,14 @@ export type FolderItem = {
|
||||
has_children: boolean;
|
||||
};
|
||||
|
||||
export type ReadingStatus = "unread" | "reading" | "read";
|
||||
|
||||
export type ReadingProgressDto = {
|
||||
status: ReadingStatus;
|
||||
current_page: number | null;
|
||||
last_read_at: string | null;
|
||||
};
|
||||
|
||||
export type BookDto = {
|
||||
id: string;
|
||||
library_id: string;
|
||||
@@ -60,6 +68,10 @@ export type BookDto = {
|
||||
file_format: string | null;
|
||||
file_parse_status: string | null;
|
||||
updated_at: string;
|
||||
// Présents uniquement sur GET /books/:id (pas dans la liste)
|
||||
reading_status?: ReadingStatus;
|
||||
reading_current_page?: number | null;
|
||||
reading_last_read_at?: string | null;
|
||||
};
|
||||
|
||||
export type BooksPageDto = {
|
||||
@@ -353,3 +365,18 @@ export async function getThumbnailStats() {
|
||||
export async function convertBook(bookId: string) {
|
||||
return apiFetch<IndexJobDto>(`/books/${bookId}/convert`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function fetchReadingProgress(bookId: string) {
|
||||
return apiFetch<ReadingProgressDto>(`/books/${bookId}/progress`);
|
||||
}
|
||||
|
||||
export async function updateReadingProgress(
|
||||
bookId: string,
|
||||
status: ReadingStatus,
|
||||
currentPage?: number,
|
||||
) {
|
||||
return apiFetch<ReadingProgressDto>(`/books/${bookId}/progress`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status, current_page: currentPage ?? null }),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user