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:
@@ -292,6 +292,8 @@ pub struct SeriesItem {
|
|||||||
pub books_read_count: i64,
|
pub books_read_count: i64,
|
||||||
#[schema(value_type = String)]
|
#[schema(value_type = String)]
|
||||||
pub first_book_id: Uuid,
|
pub first_book_id: Uuid,
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub library_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -460,6 +462,7 @@ pub async fn list_series(
|
|||||||
book_count: row.get("book_count"),
|
book_count: row.get("book_count"),
|
||||||
books_read_count: row.get("books_read_count"),
|
books_read_count: row.get("books_read_count"),
|
||||||
first_book_id: row.get("first_book_id"),
|
first_book_id: row.get("first_book_id"),
|
||||||
|
library_id,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -471,6 +474,186 @@ pub async fn list_series(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct ListAllSeriesQuery {
|
||||||
|
#[schema(value_type = Option<String>, example = "dragon")]
|
||||||
|
pub q: Option<String>,
|
||||||
|
#[schema(value_type = Option<String>)]
|
||||||
|
pub library_id: Option<Uuid>,
|
||||||
|
#[schema(value_type = Option<String>, example = "unread,reading")]
|
||||||
|
pub reading_status: Option<String>,
|
||||||
|
#[schema(value_type = Option<i64>, example = 1)]
|
||||||
|
pub page: Option<i64>,
|
||||||
|
#[schema(value_type = Option<i64>, example = 50)]
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all series across libraries with optional filtering and pagination
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/series",
|
||||||
|
tag = "books",
|
||||||
|
params(
|
||||||
|
("q" = Option<String>, Query, description = "Filter by series name (case-insensitive, partial match)"),
|
||||||
|
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
||||||
|
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
|
||||||
|
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
||||||
|
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, body = SeriesPage),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(("Bearer" = []))
|
||||||
|
)]
|
||||||
|
pub async fn list_all_series(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<ListAllSeriesQuery>,
|
||||||
|
) -> Result<Json<SeriesPage>, ApiError> {
|
||||||
|
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||||
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
|
let offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let reading_statuses: Option<Vec<String>> = query.reading_status.as_deref().map(|s| {
|
||||||
|
s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
let series_status_expr = r#"CASE
|
||||||
|
WHEN sc.books_read_count = sc.book_count THEN 'read'
|
||||||
|
WHEN sc.books_read_count = 0 THEN 'unread'
|
||||||
|
ELSE 'reading'
|
||||||
|
END"#;
|
||||||
|
|
||||||
|
let mut p: usize = 0;
|
||||||
|
|
||||||
|
let lib_cond = if query.library_id.is_some() {
|
||||||
|
p += 1; format!("WHERE library_id = ${p}")
|
||||||
|
} else {
|
||||||
|
"WHERE TRUE".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let q_cond = if query.q.is_some() {
|
||||||
|
p += 1; format!("AND sc.name ILIKE ${p}")
|
||||||
|
} else { String::new() };
|
||||||
|
|
||||||
|
let rs_cond = if reading_statuses.is_some() {
|
||||||
|
p += 1; format!("AND {series_status_expr} = ANY(${p})")
|
||||||
|
} else { String::new() };
|
||||||
|
|
||||||
|
let count_sql = format!(
|
||||||
|
r#"
|
||||||
|
WITH sorted_books AS (
|
||||||
|
SELECT COALESCE(NULLIF(series, ''), 'unclassified') as name, id
|
||||||
|
FROM books {lib_cond}
|
||||||
|
),
|
||||||
|
series_counts AS (
|
||||||
|
SELECT sb.name,
|
||||||
|
COUNT(*) as book_count,
|
||||||
|
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
||||||
|
FROM sorted_books sb
|
||||||
|
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||||
|
GROUP BY sb.name
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) FROM series_counts sc WHERE TRUE {q_cond} {rs_cond}
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
|
||||||
|
let limit_p = p + 1;
|
||||||
|
let offset_p = p + 2;
|
||||||
|
|
||||||
|
let data_sql = format!(
|
||||||
|
r#"
|
||||||
|
WITH sorted_books AS (
|
||||||
|
SELECT
|
||||||
|
COALESCE(NULLIF(series, ''), 'unclassified') as name,
|
||||||
|
id,
|
||||||
|
library_id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
||||||
|
ORDER BY
|
||||||
|
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
||||||
|
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
||||||
|
title ASC
|
||||||
|
) as rn
|
||||||
|
FROM books
|
||||||
|
{lib_cond}
|
||||||
|
),
|
||||||
|
series_counts AS (
|
||||||
|
SELECT
|
||||||
|
sb.name,
|
||||||
|
COUNT(*) as book_count,
|
||||||
|
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
||||||
|
FROM sorted_books sb
|
||||||
|
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||||
|
GROUP BY sb.name
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
sc.name,
|
||||||
|
sc.book_count,
|
||||||
|
sc.books_read_count,
|
||||||
|
sb.id as first_book_id,
|
||||||
|
sb.library_id
|
||||||
|
FROM series_counts sc
|
||||||
|
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
||||||
|
WHERE TRUE
|
||||||
|
{q_cond}
|
||||||
|
{rs_cond}
|
||||||
|
ORDER BY
|
||||||
|
REGEXP_REPLACE(LOWER(sc.name), '[0-9]+', '', 'g'),
|
||||||
|
COALESCE(
|
||||||
|
(REGEXP_MATCH(LOWER(sc.name), '\d+'))[1]::int,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
sc.name ASC
|
||||||
|
LIMIT ${limit_p} OFFSET ${offset_p}
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
|
||||||
|
let q_pattern = query.q.as_deref().map(|q| format!("%{}%", q));
|
||||||
|
|
||||||
|
let mut count_builder = sqlx::query(&count_sql);
|
||||||
|
let mut data_builder = sqlx::query(&data_sql);
|
||||||
|
|
||||||
|
if let Some(lib_id) = query.library_id {
|
||||||
|
count_builder = count_builder.bind(lib_id);
|
||||||
|
data_builder = data_builder.bind(lib_id);
|
||||||
|
}
|
||||||
|
if let Some(ref pat) = q_pattern {
|
||||||
|
count_builder = count_builder.bind(pat);
|
||||||
|
data_builder = data_builder.bind(pat);
|
||||||
|
}
|
||||||
|
if let Some(ref statuses) = reading_statuses {
|
||||||
|
count_builder = count_builder.bind(statuses.clone());
|
||||||
|
data_builder = data_builder.bind(statuses.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
data_builder = data_builder.bind(limit).bind(offset);
|
||||||
|
|
||||||
|
let (count_row, rows) = tokio::try_join!(
|
||||||
|
count_builder.fetch_one(&state.pool),
|
||||||
|
data_builder.fetch_all(&state.pool),
|
||||||
|
)?;
|
||||||
|
let total: i64 = count_row.get(0);
|
||||||
|
|
||||||
|
let items: Vec<SeriesItem> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| SeriesItem {
|
||||||
|
name: row.get("name"),
|
||||||
|
book_count: row.get("book_count"),
|
||||||
|
books_read_count: row.get("books_read_count"),
|
||||||
|
first_book_id: row.get("first_book_id"),
|
||||||
|
library_id: row.get("library_id"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(SeriesPage {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct OngoingQuery {
|
pub struct OngoingQuery {
|
||||||
#[schema(value_type = Option<i64>, example = 10)]
|
#[schema(value_type = Option<i64>, example = 10)]
|
||||||
@@ -517,6 +700,7 @@ pub async fn ongoing_series(
|
|||||||
SELECT
|
SELECT
|
||||||
COALESCE(NULLIF(series, ''), 'unclassified') AS name,
|
COALESCE(NULLIF(series, ''), 'unclassified') AS name,
|
||||||
id,
|
id,
|
||||||
|
library_id,
|
||||||
ROW_NUMBER() OVER (
|
ROW_NUMBER() OVER (
|
||||||
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@@ -526,7 +710,7 @@ pub async fn ongoing_series(
|
|||||||
) AS rn
|
) AS rn
|
||||||
FROM books
|
FROM books
|
||||||
)
|
)
|
||||||
SELECT ss.name, ss.book_count, ss.books_read_count, fb.id AS first_book_id
|
SELECT ss.name, ss.book_count, ss.books_read_count, fb.id AS first_book_id, fb.library_id
|
||||||
FROM series_stats ss
|
FROM series_stats ss
|
||||||
JOIN first_books fb ON fb.name = ss.name AND fb.rn = 1
|
JOIN first_books fb ON fb.name = ss.name AND fb.rn = 1
|
||||||
ORDER BY ss.last_read_at DESC NULLS LAST
|
ORDER BY ss.last_read_at DESC NULLS LAST
|
||||||
@@ -544,6 +728,7 @@ pub async fn ongoing_series(
|
|||||||
book_count: row.get("book_count"),
|
book_count: row.get("book_count"),
|
||||||
books_read_count: row.get("books_read_count"),
|
books_read_count: row.get("books_read_count"),
|
||||||
first_book_id: row.get("first_book_id"),
|
first_book_id: row.get("first_book_id"),
|
||||||
|
library_id: row.get("library_id"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/books/:id/pages/:n", get(pages::get_page))
|
.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("/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("/libraries/:library_id/series", get(books::list_series))
|
||||||
|
.route("/series", get(books::list_all_series))
|
||||||
.route("/series/ongoing", get(books::ongoing_series))
|
.route("/series/ongoing", get(books::ongoing_series))
|
||||||
|
.route("/series/mark-read", axum::routing::post(reading_progress::mark_series_read))
|
||||||
.route("/search", get(search::search_books))
|
.route("/search", get(search::search_books))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))
|
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ use utoipa::OpenApi;
|
|||||||
crate::books::get_book,
|
crate::books::get_book,
|
||||||
crate::reading_progress::get_reading_progress,
|
crate::reading_progress::get_reading_progress,
|
||||||
crate::reading_progress::update_reading_progress,
|
crate::reading_progress::update_reading_progress,
|
||||||
|
crate::reading_progress::mark_series_read,
|
||||||
crate::books::get_thumbnail,
|
crate::books::get_thumbnail,
|
||||||
crate::books::list_series,
|
crate::books::list_series,
|
||||||
|
crate::books::list_all_series,
|
||||||
crate::books::ongoing_series,
|
crate::books::ongoing_series,
|
||||||
crate::books::ongoing_books,
|
crate::books::ongoing_books,
|
||||||
crate::books::convert_book,
|
crate::books::convert_book,
|
||||||
@@ -49,8 +51,11 @@ use utoipa::OpenApi;
|
|||||||
crate::books::BookDetails,
|
crate::books::BookDetails,
|
||||||
crate::reading_progress::ReadingProgressResponse,
|
crate::reading_progress::ReadingProgressResponse,
|
||||||
crate::reading_progress::UpdateReadingProgressRequest,
|
crate::reading_progress::UpdateReadingProgressRequest,
|
||||||
|
crate::reading_progress::MarkSeriesReadRequest,
|
||||||
|
crate::reading_progress::MarkSeriesReadResponse,
|
||||||
crate::books::SeriesItem,
|
crate::books::SeriesItem,
|
||||||
crate::books::SeriesPage,
|
crate::books::SeriesPage,
|
||||||
|
crate::books::ListAllSeriesQuery,
|
||||||
crate::books::OngoingQuery,
|
crate::books::OngoingQuery,
|
||||||
crate::pages::PageQuery,
|
crate::pages::PageQuery,
|
||||||
crate::search::SearchQuery,
|
crate::search::SearchQuery,
|
||||||
|
|||||||
@@ -165,3 +165,83 @@ pub async fn update_reading_progress(
|
|||||||
last_read_at: row.get("last_read_at"),
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
17
apps/backoffice/app/api/books/[bookId]/progress/route.ts
Normal file
17
apps/backoffice/app/api/books/[bookId]/progress/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { updateReadingProgress } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ bookId: string }> }
|
||||||
|
) {
|
||||||
|
const { bookId } = await params;
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = await updateReadingProgress(bookId, body.status, body.current_page ?? undefined);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to update reading progress";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/backoffice/app/api/series/mark-read/route.ts
Normal file
13
apps/backoffice/app/api/series/mark-read/route.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { markSeriesRead } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = await markSeriesRead(body.series, body.status ?? "read");
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to mark series";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "../../../lib/api";
|
import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "../../../lib/api";
|
||||||
import { BookPreview } from "../../components/BookPreview";
|
import { BookPreview } from "../../components/BookPreview";
|
||||||
import { ConvertButton } from "../../components/ConvertButton";
|
import { ConvertButton } from "../../components/ConvertButton";
|
||||||
|
import { MarkBookReadButton } from "../../components/MarkBookReadButton";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
@@ -105,11 +106,14 @@ export default async function BookDetailPage({
|
|||||||
{book.reading_status && (
|
{book.reading_status && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted-foreground">Lecture :</span>
|
<span className="text-sm text-muted-foreground">Lecture :</span>
|
||||||
<ReadingStatusBadge
|
<div className="flex items-center gap-3">
|
||||||
status={book.reading_status}
|
<ReadingStatusBadge
|
||||||
currentPage={book.reading_current_page ?? null}
|
status={book.reading_status}
|
||||||
lastReadAt={book.reading_last_read_at ?? null}
|
currentPage={book.reading_current_page ?? null}
|
||||||
/>
|
lastReadAt={book.reading_last_read_at ?? null}
|
||||||
|
/>
|
||||||
|
<MarkBookReadButton bookId={book.id} currentStatus={book.reading_status} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "../../lib/api";
|
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "../../lib/api";
|
||||||
import { BooksGrid, EmptyState } from "../components/BookCard";
|
import { BooksGrid, EmptyState } from "../components/BookCard";
|
||||||
import { Card, CardContent, Button, FormField, FormInput, FormSelect, FormRow, OffsetPagination } from "../components/ui";
|
import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||||
|
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export default async function BooksPage({
|
|||||||
const searchParamsAwaited = await searchParams;
|
const searchParamsAwaited = await searchParams;
|
||||||
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
||||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||||
|
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ export default async function BooksPage({
|
|||||||
totalHits = searchResponse.estimated_total_hits;
|
totalHits = searchResponse.estimated_total_hits;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const booksPage = await fetchBooks(libraryId, undefined, page, limit).catch(() => ({
|
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus).catch(() => ({
|
||||||
items: [] as BookDto[],
|
items: [] as BookDto[],
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -69,6 +71,20 @@ export default async function BooksPage({
|
|||||||
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
const libraryOptions = [
|
||||||
|
{ value: "", label: "All libraries" },
|
||||||
|
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: "", label: "All" },
|
||||||
|
{ value: "unread", label: "Unread" },
|
||||||
|
{ value: "reading", label: "In progress" },
|
||||||
|
{ value: "read", label: "Read" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasFilters = searchQuery || libraryId || readingStatus;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -80,66 +96,28 @@ export default async function BooksPage({
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Bar - Style compact et propre */}
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<form className="flex flex-col sm:flex-row gap-3 items-start sm:items-end">
|
<LiveSearchForm
|
||||||
<FormField className="flex-1 w-full">
|
basePath="/books"
|
||||||
<label className="block text-sm font-medium text-foreground mb-1.5">Search</label>
|
fields={[
|
||||||
<FormInput
|
{ name: "q", type: "text", label: "Search", placeholder: "Search by title, author, series...", className: "flex-1 w-full" },
|
||||||
name="q"
|
{ name: "library", type: "select", label: "Library", options: libraryOptions, className: "w-full sm:w-48" },
|
||||||
placeholder="Search by title, author, series..."
|
{ name: "status", type: "select", label: "Status", options: statusOptions, className: "w-full sm:w-40" },
|
||||||
defaultValue={searchQuery}
|
]}
|
||||||
className="w-full"
|
/>
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField className="w-full sm:w-48">
|
|
||||||
<label className="block text-sm font-medium text-foreground mb-1.5">Library</label>
|
|
||||||
<FormSelect name="library" defaultValue={libraryId || ""}>
|
|
||||||
<option value="">All libraries</option>
|
|
||||||
{libraries.map((lib) => (
|
|
||||||
<option key={lib.id} value={lib.id}>
|
|
||||||
{lib.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</FormSelect>
|
|
||||||
</FormField>
|
|
||||||
<div className="flex gap-2 w-full sm:w-auto">
|
|
||||||
<Button type="submit" className="flex-1 sm:flex-none">
|
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
Search
|
|
||||||
</Button>
|
|
||||||
{searchQuery && (
|
|
||||||
<Link
|
|
||||||
href="/books"
|
|
||||||
className="
|
|
||||||
inline-flex items-center justify-center
|
|
||||||
h-10 px-4
|
|
||||||
border border-input
|
|
||||||
text-sm font-medium
|
|
||||||
text-muted-foreground
|
|
||||||
bg-background
|
|
||||||
rounded-md
|
|
||||||
hover:bg-accent hover:text-accent-foreground
|
|
||||||
transition-colors duration-200
|
|
||||||
flex-1 sm:flex-none
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Résultats */}
|
{/* Résultats */}
|
||||||
{searchQuery && totalHits !== null && (
|
{searchQuery && totalHits !== null ? (
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Found {totalHits} result{totalHits !== 1 ? 's' : ''} for "{searchQuery}"
|
Found {totalHits} result{totalHits !== 1 ? 's' : ''} for "{searchQuery}"
|
||||||
</p>
|
</p>
|
||||||
|
) : !searchQuery && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{total} book{total !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Séries matchantes */}
|
{/* Séries matchantes */}
|
||||||
@@ -150,7 +128,7 @@ export default async function BooksPage({
|
|||||||
{seriesHits.map((s) => (
|
{seriesHits.map((s) => (
|
||||||
<Link
|
<Link
|
||||||
key={`${s.library_id}-${s.name}`}
|
key={`${s.library_id}-${s.name}`}
|
||||||
href={`/libraries/${s.library_id}/books?series=${encodeURIComponent(s.name)}`}
|
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
|
||||||
className="group"
|
className="group"
|
||||||
>
|
>
|
||||||
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md transition-shadow duration-200">
|
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md transition-shadow duration-200">
|
||||||
|
|||||||
@@ -61,10 +61,12 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
|
|||||||
const status = readingStatus ?? book.reading_status;
|
const status = readingStatus ?? book.reading_status;
|
||||||
const overlay = status ? readingStatusOverlay[status] : null;
|
const overlay = status ? readingStatusOverlay[status] : null;
|
||||||
|
|
||||||
|
const isRead = status === "read";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/books/${book.id}`}
|
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"
|
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 ${isRead ? "opacity-50" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<BookImage
|
<BookImage
|
||||||
|
|||||||
128
apps/backoffice/app/components/LiveSearchForm.tsx
Normal file
128
apps/backoffice/app/components/LiveSearchForm.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useCallback, useEffect } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
interface FieldDef {
|
||||||
|
name: string;
|
||||||
|
type: "text" | "select";
|
||||||
|
placeholder?: string;
|
||||||
|
label: string;
|
||||||
|
options?: { value: string; label: string }[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LiveSearchFormProps {
|
||||||
|
fields: FieldDef[];
|
||||||
|
basePath: string;
|
||||||
|
debounceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearchFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
const buildUrl = useCallback((): string => {
|
||||||
|
if (!formRef.current) return basePath;
|
||||||
|
const formData = new FormData(formRef.current);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
const str = value.toString().trim();
|
||||||
|
if (str) params.set(key, str);
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `${basePath}?${qs}` : basePath;
|
||||||
|
}, [basePath]);
|
||||||
|
|
||||||
|
const navigate = useCallback((immediate: boolean) => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
if (immediate) {
|
||||||
|
router.replace(buildUrl() as any);
|
||||||
|
} else {
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
router.replace(buildUrl() as any);
|
||||||
|
}, debounceMs);
|
||||||
|
}
|
||||||
|
}, [router, buildUrl, debounceMs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasFilters = fields.some((f) => {
|
||||||
|
const val = searchParams.get(f.name);
|
||||||
|
return val && val.trim() !== "";
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
router.replace(buildUrl() as any);
|
||||||
|
}}
|
||||||
|
className="flex flex-col sm:flex-row gap-3 items-start sm:items-end"
|
||||||
|
>
|
||||||
|
{fields.map((field) =>
|
||||||
|
field.type === "text" ? (
|
||||||
|
<div key={field.name} className={field.className || "flex-1 w-full"}>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||||
|
{field.label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name={field.name}
|
||||||
|
type="text"
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
defaultValue={searchParams.get(field.name) || ""}
|
||||||
|
onChange={() => navigate(false)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={field.name} className={field.className || "w-full sm:w-48"}>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1.5">
|
||||||
|
{field.label}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name={field.name}
|
||||||
|
defaultValue={searchParams.get(field.name) || ""}
|
||||||
|
onChange={() => navigate(true)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
{field.options?.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{hasFilters && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.replace(basePath as any)}
|
||||||
|
className="
|
||||||
|
inline-flex items-center justify-center
|
||||||
|
h-10 px-4
|
||||||
|
border border-input
|
||||||
|
text-sm font-medium
|
||||||
|
text-muted-foreground
|
||||||
|
bg-background
|
||||||
|
rounded-md
|
||||||
|
hover:bg-accent hover:text-accent-foreground
|
||||||
|
transition-colors duration-200
|
||||||
|
w-full sm:w-auto
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
apps/backoffice/app/components/MarkBookReadButton.tsx
Normal file
64
apps/backoffice/app/components/MarkBookReadButton.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "./ui";
|
||||||
|
|
||||||
|
interface MarkBookReadButtonProps {
|
||||||
|
bookId: string;
|
||||||
|
currentStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkBookReadButton({ bookId, currentStatus }: MarkBookReadButtonProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const isRead = currentStatus === "read";
|
||||||
|
const targetStatus = isRead ? "unread" : "read";
|
||||||
|
const label = isRead ? "Marquer non lu" : "Marquer comme lu";
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/books/${bookId}/progress`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: targetStatus }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
console.error("Failed to update reading progress:", body.error);
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update reading progress:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={isRead ? "outline" : "primary"}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
) : isRead ? (
|
||||||
|
<svg className="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{!loading && label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/backoffice/app/components/MarkSeriesReadButton.tsx
Normal file
74
apps/backoffice/app/components/MarkSeriesReadButton.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface MarkSeriesReadButtonProps {
|
||||||
|
seriesName: string;
|
||||||
|
bookCount: number;
|
||||||
|
booksReadCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkSeriesReadButton({ seriesName, bookCount, booksReadCount }: MarkSeriesReadButtonProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const allRead = booksReadCount >= bookCount;
|
||||||
|
const targetStatus = allRead ? "unread" : "read";
|
||||||
|
const label = allRead ? "Marquer non lu" : "Tout marquer lu";
|
||||||
|
|
||||||
|
const handleClick = async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/series/mark-read", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ series: seriesName, status: targetStatus }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
console.error("Failed to mark series:", body.error);
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to mark series:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={loading}
|
||||||
|
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full font-medium transition-colors ${
|
||||||
|
allRead
|
||||||
|
? "bg-green-500/15 text-green-600 dark:text-green-400 hover:bg-green-500/25"
|
||||||
|
: "bg-muted/50 text-muted-foreground hover:bg-primary/10 hover:text-primary"
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
) : allRead ? (
|
||||||
|
<>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
|
||||||
|
</svg>
|
||||||
|
{label}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
|
||||||
|
</svg>
|
||||||
|
{label}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,9 +6,9 @@ import Link from "next/link";
|
|||||||
import { NavIcon } from "./ui";
|
import { NavIcon } from "./ui";
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
href: "/" | "/books" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
href: "/" | "/books" | "/series" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||||
label: string;
|
label: string;
|
||||||
icon: "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "settings";
|
icon: "dashboard" | "books" | "series" | "libraries" | "jobs" | "tokens" | "settings";
|
||||||
};
|
};
|
||||||
|
|
||||||
const HamburgerIcon = () => (
|
const HamburgerIcon = () => (
|
||||||
|
|||||||
@@ -15,14 +15,15 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
href: "/" | "/books" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
href: "/" | "/books" | "/series" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||||
label: string;
|
label: string;
|
||||||
icon: "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "settings";
|
icon: "dashboard" | "books" | "series" | "libraries" | "jobs" | "tokens" | "settings";
|
||||||
};
|
};
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ href: "/", label: "Dashboard", icon: "dashboard" },
|
{ href: "/", label: "Dashboard", icon: "dashboard" },
|
||||||
{ href: "/books", label: "Books", icon: "books" },
|
{ href: "/books", label: "Books", icon: "books" },
|
||||||
|
{ href: "/series", label: "Series", icon: "series" },
|
||||||
{ href: "/libraries", label: "Libraries", icon: "libraries" },
|
{ href: "/libraries", label: "Libraries", icon: "libraries" },
|
||||||
{ href: "/jobs", label: "Jobs", icon: "jobs" },
|
{ href: "/jobs", label: "Jobs", icon: "jobs" },
|
||||||
{ href: "/tokens", label: "Tokens", icon: "tokens" },
|
{ href: "/tokens", label: "Tokens", icon: "tokens" },
|
||||||
|
|||||||
138
apps/backoffice/app/libraries/[id]/series/[name]/page.tsx
Normal file
138
apps/backoffice/app/libraries/[id]/series/[name]/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { fetchLibraries, fetchBooks, getBookCoverUrl, BookDto } from "../../../../../lib/api";
|
||||||
|
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
|
||||||
|
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
|
||||||
|
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
|
||||||
|
import { OffsetPagination } from "../../../../components/ui";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function SeriesDetailPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; name: string }>;
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}) {
|
||||||
|
const { id, name } = await params;
|
||||||
|
const searchParamsAwaited = await searchParams;
|
||||||
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
|
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 50;
|
||||||
|
|
||||||
|
const seriesName = decodeURIComponent(name);
|
||||||
|
|
||||||
|
const [library, booksPage] = await Promise.all([
|
||||||
|
fetchLibraries().then((libs) => libs.find((l) => l.id === id)),
|
||||||
|
fetchBooks(id, seriesName, page, limit).catch(() => ({
|
||||||
|
items: [] as BookDto[],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
limit,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!library) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const books = booksPage.items.map((book) => ({
|
||||||
|
...book,
|
||||||
|
coverUrl: getBookCoverUrl(book.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(booksPage.total / limit);
|
||||||
|
const booksReadCount = booksPage.items.filter((b) => b.reading_status === "read").length;
|
||||||
|
const displayName = seriesName === "unclassified" ? "Non classifié" : seriesName;
|
||||||
|
|
||||||
|
// Use first book cover as series cover
|
||||||
|
const coverBookId = booksPage.items[0]?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Link
|
||||||
|
href="/libraries"
|
||||||
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Libraries
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<Link
|
||||||
|
href={`/libraries/${id}/series`}
|
||||||
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{library.name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-foreground font-medium">{displayName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Series Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-6">
|
||||||
|
{coverBookId && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-40 aspect-[2/3] relative rounded-xl overflow-hidden shadow-card border border-border">
|
||||||
|
<Image
|
||||||
|
src={getBookCoverUrl(coverBookId)}
|
||||||
|
alt={`Cover of ${displayName}`}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">{displayName}</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<span className="font-semibold text-foreground">{booksPage.total}</span> livre{booksPage.total !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
<span className="w-px h-4 bg-border" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<span className="font-semibold text-foreground">{booksReadCount}</span>/{booksPage.total} lu{booksPage.total !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-[120px] max-w-[200px]">
|
||||||
|
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-green-500 rounded-full transition-all"
|
||||||
|
style={{ width: `${booksPage.total > 0 ? (booksReadCount / booksPage.total) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MarkSeriesReadButton
|
||||||
|
seriesName={seriesName}
|
||||||
|
bookCount={booksPage.total}
|
||||||
|
booksReadCount={booksReadCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Books Grid */}
|
||||||
|
{books.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<BooksGrid books={books} />
|
||||||
|
<OffsetPagination
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
pageSize={limit}
|
||||||
|
totalItems={booksPage.total}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState message="Aucun livre dans cette série" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
|
import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
|
||||||
import { OffsetPagination } from "../../../components/ui";
|
import { OffsetPagination } from "../../../components/ui";
|
||||||
|
import { MarkSeriesReadButton } from "../../../components/MarkSeriesReadButton";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
@@ -50,10 +51,10 @@ export default async function LibrarySeriesPage({
|
|||||||
{series.map((s) => (
|
{series.map((s) => (
|
||||||
<Link
|
<Link
|
||||||
key={s.name}
|
key={s.name}
|
||||||
href={`/libraries/${id}/books?series=${encodeURIComponent(s.name)}`}
|
href={`/libraries/${id}/series/${encodeURIComponent(s.name)}`}
|
||||||
className="group"
|
className="group"
|
||||||
>
|
>
|
||||||
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md transition-shadow duration-200">
|
<div className={`bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md transition-shadow duration-200 ${s.books_read_count >= s.book_count ? "opacity-50" : ""}`}>
|
||||||
<div className="aspect-[2/3] relative bg-muted/50">
|
<div className="aspect-[2/3] relative bg-muted/50">
|
||||||
<Image
|
<Image
|
||||||
src={getBookCoverUrl(s.first_book_id)}
|
src={getBookCoverUrl(s.first_book_id)}
|
||||||
@@ -67,9 +68,16 @@ export default async function LibrarySeriesPage({
|
|||||||
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
||||||
{s.name === "unclassified" ? "Unclassified" : s.name}
|
{s.name === "unclassified" ? "Unclassified" : s.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<div className="flex items-center justify-between mt-1">
|
||||||
{s.book_count} book{s.book_count !== 1 ? 's' : ''}
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
{s.books_read_count}/{s.book_count} lu{s.book_count !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
<MarkSeriesReadButton
|
||||||
|
seriesName={s.name}
|
||||||
|
bookCount={s.book_count}
|
||||||
|
booksReadCount={s.books_read_count}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
140
apps/backoffice/app/series/page.tsx
Normal file
140
apps/backoffice/app/series/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { fetchAllSeries, fetchLibraries, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "../../lib/api";
|
||||||
|
import { MarkSeriesReadButton } from "../components/MarkSeriesReadButton";
|
||||||
|
import { LiveSearchForm } from "../components/LiveSearchForm";
|
||||||
|
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function SeriesPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}) {
|
||||||
|
const searchParamsAwaited = await searchParams;
|
||||||
|
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
||||||
|
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
||||||
|
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
||||||
|
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
|
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||||
|
|
||||||
|
const [libraries, seriesPage] = await Promise.all([
|
||||||
|
fetchLibraries().catch(() => [] as LibraryDto[]),
|
||||||
|
fetchAllSeries(libraryId, searchQuery || undefined, readingStatus, page, limit).catch(
|
||||||
|
() => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const series = seriesPage.items;
|
||||||
|
const totalPages = Math.ceil(seriesPage.total / limit);
|
||||||
|
const hasFilters = searchQuery || libraryId || readingStatus;
|
||||||
|
|
||||||
|
const libraryOptions = [
|
||||||
|
{ value: "", label: "All libraries" },
|
||||||
|
...libraries.map((lib) => ({ value: lib.id, label: lib.name })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: "", label: "All" },
|
||||||
|
{ value: "unread", label: "Unread" },
|
||||||
|
{ value: "reading", label: "In progress" },
|
||||||
|
{ value: "read", label: "Read" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||||
|
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||||
|
</svg>
|
||||||
|
Series
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<LiveSearchForm
|
||||||
|
basePath="/series"
|
||||||
|
fields={[
|
||||||
|
{ name: "q", type: "text", label: "Search", placeholder: "Search by series name...", className: "flex-1 w-full" },
|
||||||
|
{ name: "library", type: "select", label: "Library", options: libraryOptions, className: "w-full sm:w-48" },
|
||||||
|
{ name: "status", type: "select", label: "Status", options: statusOptions, className: "w-full sm:w-40" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Results count */}
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{seriesPage.total} series
|
||||||
|
{searchQuery && <> matching "{searchQuery}"</>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Series Grid */}
|
||||||
|
{series.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||||
|
{series.map((s) => (
|
||||||
|
<Link
|
||||||
|
key={s.name}
|
||||||
|
href={`/libraries/${s.library_id}/series/${encodeURIComponent(s.name)}`}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden hover:shadow-md hover:-translate-y-1 transition-all duration-200 ${
|
||||||
|
s.books_read_count >= s.book_count ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="aspect-[2/3] relative bg-muted/50">
|
||||||
|
<Image
|
||||||
|
src={getBookCoverUrl(s.first_book_id)}
|
||||||
|
alt={`Cover of ${s.name}`}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<h3 className="font-medium text-foreground truncate text-sm" title={s.name}>
|
||||||
|
{s.name === "unclassified" ? "Unclassified" : s.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{s.books_read_count}/{s.book_count} lu{s.book_count !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
<MarkSeriesReadButton
|
||||||
|
seriesName={s.name}
|
||||||
|
bookCount={s.book_count}
|
||||||
|
booksReadCount={s.books_read_count}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OffsetPagination
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
pageSize={limit}
|
||||||
|
totalItems={seriesPage.total}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<div className="w-16 h-16 mb-4 text-muted-foreground/30">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
{hasFilters ? "No series found matching your filters" : "No series available"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -112,6 +112,7 @@ export type SeriesDto = {
|
|||||||
book_count: number;
|
book_count: number;
|
||||||
books_read_count: number;
|
books_read_count: number;
|
||||||
first_book_id: string;
|
first_book_id: string;
|
||||||
|
library_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function config() {
|
export function config() {
|
||||||
@@ -263,10 +264,12 @@ export async function fetchBooks(
|
|||||||
series?: string,
|
series?: string,
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 50,
|
limit: number = 50,
|
||||||
|
readingStatus?: string,
|
||||||
): Promise<BooksPageDto> {
|
): Promise<BooksPageDto> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (libraryId) params.set("library_id", libraryId);
|
if (libraryId) params.set("library_id", libraryId);
|
||||||
if (series) params.set("series", series);
|
if (series) params.set("series", series);
|
||||||
|
if (readingStatus) params.set("reading_status", readingStatus);
|
||||||
params.set("page", page.toString());
|
params.set("page", page.toString());
|
||||||
params.set("limit", limit.toString());
|
params.set("limit", limit.toString());
|
||||||
|
|
||||||
@@ -294,6 +297,23 @@ export async function fetchSeries(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAllSeries(
|
||||||
|
libraryId?: string,
|
||||||
|
q?: string,
|
||||||
|
readingStatus?: string,
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 50,
|
||||||
|
): Promise<SeriesPageDto> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (libraryId) params.set("library_id", libraryId);
|
||||||
|
if (q) params.set("q", q);
|
||||||
|
if (readingStatus) params.set("reading_status", readingStatus);
|
||||||
|
params.set("page", page.toString());
|
||||||
|
params.set("limit", limit.toString());
|
||||||
|
|
||||||
|
return apiFetch<SeriesPageDto>(`/series?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchBooks(
|
export async function searchBooks(
|
||||||
query: string,
|
query: string,
|
||||||
libraryId?: string,
|
libraryId?: string,
|
||||||
@@ -398,3 +418,10 @@ export async function updateReadingProgress(
|
|||||||
body: JSON.stringify({ status, current_page: currentPage ?? null }),
|
body: JSON.stringify({ status, current_page: currentPage ?? null }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function markSeriesRead(seriesName: string, status: "read" | "unread" = "read") {
|
||||||
|
return apiFetch<{ updated: number }>("/series/mark-read", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ series: seriesName, status }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user