feat(backoffice): add reading progress management, series page, and live search

- API: add POST /series/mark-read to batch mark all books in a series
- API: add GET /series cross-library endpoint with search, library and status filters
- API: add library_id to SeriesItem response
- Backoffice: mark book as read/unread button on book detail page
- Backoffice: mark series as read/unread button on series cards
- Backoffice: new /series top-level page with search and filters
- Backoffice: new /libraries/[id]/series/[name] series detail page
- Backoffice: opacity on fully read books and series cards
- Backoffice: live search with debounce on books and series pages
- Backoffice: reading status filter on books and series pages
- Fix $2 -> $1 parameter binding in mark-series-read SQL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 18:17:16 +01:00
parent fd277602c9
commit 1d25c8869f
18 changed files with 940 additions and 74 deletions

View File

@@ -165,3 +165,83 @@ pub async fn update_reading_progress(
last_read_at: row.get("last_read_at"),
}))
}
#[derive(Deserialize, ToSchema)]
pub struct MarkSeriesReadRequest {
/// Series name (use "unclassified" for books without series)
pub series: String,
/// Status to set: "read" or "unread"
pub status: String,
}
#[derive(Serialize, ToSchema)]
pub struct MarkSeriesReadResponse {
pub updated: i64,
}
/// Mark all books in a series as read or unread
#[utoipa::path(
post,
path = "/series/mark-read",
tag = "reading-progress",
request_body = MarkSeriesReadRequest,
responses(
(status = 200, body = MarkSeriesReadResponse),
(status = 422, description = "Invalid status"),
(status = 401, description = "Unauthorized"),
),
security(("Bearer" = []))
)]
pub async fn mark_series_read(
State(state): State<AppState>,
Json(body): Json<MarkSeriesReadRequest>,
) -> Result<Json<MarkSeriesReadResponse>, ApiError> {
if !["read", "unread"].contains(&body.status.as_str()) {
return Err(ApiError::bad_request(
"status must be 'read' or 'unread'",
));
}
let series_filter = if body.series == "unclassified" {
"(series IS NULL OR series = '')"
} else {
"series = $1"
};
let sql = if body.status == "unread" {
// Delete progress records to reset to unread
format!(
r#"
WITH target_books AS (
SELECT id FROM books WHERE {series_filter}
)
DELETE FROM book_reading_progress
WHERE book_id IN (SELECT id FROM target_books)
"#
)
} else {
format!(
r#"
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
SELECT id, 'read', NULL, NOW(), NOW()
FROM books
WHERE {series_filter}
ON CONFLICT (book_id) DO UPDATE
SET status = 'read',
current_page = NULL,
last_read_at = NOW(),
updated_at = NOW()
"#
)
};
let result = if body.series == "unclassified" {
sqlx::query(&sql).execute(&state.pool).await?
} else {
sqlx::query(&sql).bind(&body.series).execute(&state.pool).await?
};
Ok(Json(MarkSeriesReadResponse {
updated: result.rows_affected() as i64,
}))
}