diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index f0b6e04..b666b94 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -292,6 +292,8 @@ pub struct SeriesItem { pub books_read_count: i64, #[schema(value_type = String)] pub first_book_id: Uuid, + #[schema(value_type = String)] + pub library_id: Uuid, } #[derive(Serialize, ToSchema)] @@ -460,6 +462,7 @@ pub async fn list_series( book_count: row.get("book_count"), books_read_count: row.get("books_read_count"), first_book_id: row.get("first_book_id"), + library_id, }) .collect(); @@ -471,6 +474,186 @@ pub async fn list_series( })) } +#[derive(Deserialize, ToSchema)] +pub struct ListAllSeriesQuery { + #[schema(value_type = Option, example = "dragon")] + pub q: Option, + #[schema(value_type = Option)] + pub library_id: Option, + #[schema(value_type = Option, example = "unread,reading")] + pub reading_status: Option, + #[schema(value_type = Option, example = 1)] + pub page: Option, + #[schema(value_type = Option, example = 50)] + pub limit: Option, +} + +/// List all series across libraries with optional filtering and pagination +#[utoipa::path( + get, + path = "/series", + tag = "books", + params( + ("q" = Option, Query, description = "Filter by series name (case-insensitive, partial match)"), + ("library_id" = Option, Query, description = "Filter by library ID"), + ("reading_status" = Option, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"), + ("page" = Option, Query, description = "Page number (1-indexed, default 1)"), + ("limit" = Option, 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, + Query(query): Query, +) -> Result, 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> = 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 = 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)] pub struct OngoingQuery { #[schema(value_type = Option, example = 10)] @@ -517,6 +700,7 @@ pub async fn ongoing_series( SELECT COALESCE(NULLIF(series, ''), 'unclassified') AS name, id, + library_id, ROW_NUMBER() OVER ( PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified') ORDER BY @@ -526,7 +710,7 @@ pub async fn ongoing_series( ) AS rn 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 JOIN first_books fb ON fb.name = ss.name AND fb.rn = 1 ORDER BY ss.last_read_at DESC NULLS LAST @@ -544,6 +728,7 @@ pub async fn ongoing_series( 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(); diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index e510d4e..23fe9ad 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -111,7 +111,9 @@ async fn main() -> anyhow::Result<()> { .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("/series", get(books::list_all_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_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit)) .route_layer(middleware::from_fn_with_state( diff --git a/apps/api/src/openapi.rs b/apps/api/src/openapi.rs index 32ab6ae..5ee8282 100644 --- a/apps/api/src/openapi.rs +++ b/apps/api/src/openapi.rs @@ -8,8 +8,10 @@ use utoipa::OpenApi; crate::books::get_book, crate::reading_progress::get_reading_progress, crate::reading_progress::update_reading_progress, + crate::reading_progress::mark_series_read, crate::books::get_thumbnail, crate::books::list_series, + crate::books::list_all_series, crate::books::ongoing_series, crate::books::ongoing_books, crate::books::convert_book, @@ -49,8 +51,11 @@ use utoipa::OpenApi; crate::books::BookDetails, crate::reading_progress::ReadingProgressResponse, crate::reading_progress::UpdateReadingProgressRequest, + crate::reading_progress::MarkSeriesReadRequest, + crate::reading_progress::MarkSeriesReadResponse, crate::books::SeriesItem, crate::books::SeriesPage, + crate::books::ListAllSeriesQuery, crate::books::OngoingQuery, crate::pages::PageQuery, crate::search::SearchQuery, diff --git a/apps/api/src/reading_progress.rs b/apps/api/src/reading_progress.rs index 89ddc69..91014d4 100644 --- a/apps/api/src/reading_progress.rs +++ b/apps/api/src/reading_progress.rs @@ -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, + Json(body): Json, +) -> Result, 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, + })) +} diff --git a/apps/backoffice/app/api/books/[bookId]/progress/route.ts b/apps/backoffice/app/api/books/[bookId]/progress/route.ts new file mode 100644 index 0000000..e02cbbb --- /dev/null +++ b/apps/backoffice/app/api/books/[bookId]/progress/route.ts @@ -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 }); + } +} diff --git a/apps/backoffice/app/api/series/mark-read/route.ts b/apps/backoffice/app/api/series/mark-read/route.ts new file mode 100644 index 0000000..2ebcdc0 --- /dev/null +++ b/apps/backoffice/app/api/series/mark-read/route.ts @@ -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 }); + } +} diff --git a/apps/backoffice/app/books/[id]/page.tsx b/apps/backoffice/app/books/[id]/page.tsx index b004618..23c907d 100644 --- a/apps/backoffice/app/books/[id]/page.tsx +++ b/apps/backoffice/app/books/[id]/page.tsx @@ -1,6 +1,7 @@ import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "../../../lib/api"; import { BookPreview } from "../../components/BookPreview"; import { ConvertButton } from "../../components/ConvertButton"; +import { MarkBookReadButton } from "../../components/MarkBookReadButton"; import Image from "next/image"; import Link from "next/link"; import { notFound } from "next/navigation"; @@ -105,11 +106,14 @@ export default async function BookDetailPage({ {book.reading_status && (
Lecture : - +
+ + +
)} diff --git a/apps/backoffice/app/books/page.tsx b/apps/backoffice/app/books/page.tsx index c90bd4f..376f4a4 100644 --- a/apps/backoffice/app/books/page.tsx +++ b/apps/backoffice/app/books/page.tsx @@ -1,6 +1,7 @@ import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, SeriesHitDto, getBookCoverUrl } from "../../lib/api"; 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 Image from "next/image"; @@ -14,6 +15,7 @@ export default async function BooksPage({ 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; @@ -52,7 +54,7 @@ export default async function BooksPage({ totalHits = searchResponse.estimated_total_hits; } } else { - const booksPage = await fetchBooks(libraryId, undefined, page, limit).catch(() => ({ + const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus).catch(() => ({ items: [] as BookDto[], total: 0, page: 1, @@ -69,6 +71,20 @@ export default async function BooksPage({ 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 ( <>
@@ -79,67 +95,29 @@ export default async function BooksPage({ Books
- - {/* Search Bar - Style compact et propre */} + -
- - - - - - - - - {libraries.map((lib) => ( - - ))} - - -
- - {searchQuery && ( - - Clear - - )} -
-
+
{/* Résultats */} - {searchQuery && totalHits !== null && ( + {searchQuery && totalHits !== null ? (

Found {totalHits} result{totalHits !== 1 ? 's' : ''} for "{searchQuery}"

+ ) : !searchQuery && ( +

+ {total} book{total !== 1 ? 's' : ''} +

)} {/* Séries matchantes */} @@ -150,7 +128,7 @@ export default async function BooksPage({ {seriesHits.map((s) => (
@@ -183,7 +161,7 @@ export default async function BooksPage({ <> {searchQuery &&

Books

} - + {!searchQuery && (
| null>(null); + const formRef = useRef(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 ( +
{ + 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" ? ( +
+ + 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" + /> +
+ ) : ( +
+ + +
+ ) + )} + {hasFilters && ( + + )} +
+ ); +} diff --git a/apps/backoffice/app/components/MarkBookReadButton.tsx b/apps/backoffice/app/components/MarkBookReadButton.tsx new file mode 100644 index 0000000..fe95975 --- /dev/null +++ b/apps/backoffice/app/components/MarkBookReadButton.tsx @@ -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 ( + + ); +} diff --git a/apps/backoffice/app/components/MarkSeriesReadButton.tsx b/apps/backoffice/app/components/MarkSeriesReadButton.tsx new file mode 100644 index 0000000..e04a948 --- /dev/null +++ b/apps/backoffice/app/components/MarkSeriesReadButton.tsx @@ -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 ( + + ); +} diff --git a/apps/backoffice/app/components/MobileNav.tsx b/apps/backoffice/app/components/MobileNav.tsx index 95d54d2..0a8a93a 100644 --- a/apps/backoffice/app/components/MobileNav.tsx +++ b/apps/backoffice/app/components/MobileNav.tsx @@ -6,9 +6,9 @@ import Link from "next/link"; import { NavIcon } from "./ui"; type NavItem = { - href: "/" | "/books" | "/libraries" | "/jobs" | "/tokens" | "/settings"; + href: "/" | "/books" | "/series" | "/libraries" | "/jobs" | "/tokens" | "/settings"; label: string; - icon: "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "settings"; + icon: "dashboard" | "books" | "series" | "libraries" | "jobs" | "tokens" | "settings"; }; const HamburgerIcon = () => ( diff --git a/apps/backoffice/app/layout.tsx b/apps/backoffice/app/layout.tsx index b6e5e23..6ebd547 100644 --- a/apps/backoffice/app/layout.tsx +++ b/apps/backoffice/app/layout.tsx @@ -15,14 +15,15 @@ export const metadata: Metadata = { }; type NavItem = { - href: "/" | "/books" | "/libraries" | "/jobs" | "/tokens" | "/settings"; + href: "/" | "/books" | "/series" | "/libraries" | "/jobs" | "/tokens" | "/settings"; label: string; - icon: "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "settings"; + icon: "dashboard" | "books" | "series" | "libraries" | "jobs" | "tokens" | "settings"; }; const navItems: NavItem[] = [ { href: "/", label: "Dashboard", icon: "dashboard" }, { href: "/books", label: "Books", icon: "books" }, + { href: "/series", label: "Series", icon: "series" }, { href: "/libraries", label: "Libraries", icon: "libraries" }, { href: "/jobs", label: "Jobs", icon: "jobs" }, { href: "/tokens", label: "Tokens", icon: "tokens" }, diff --git a/apps/backoffice/app/libraries/[id]/series/[name]/page.tsx b/apps/backoffice/app/libraries/[id]/series/[name]/page.tsx new file mode 100644 index 0000000..a8e3114 --- /dev/null +++ b/apps/backoffice/app/libraries/[id]/series/[name]/page.tsx @@ -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 ( +
+ {/* Breadcrumb */} +
+ + Libraries + + / + + {library.name} + + / + {displayName} +
+ + {/* Series Header */} +
+ {coverBookId && ( +
+
+ {`Cover +
+
+ )} + +
+

{displayName}

+ +
+ + {booksPage.total} livre{booksPage.total !== 1 ? "s" : ""} + + + + {booksReadCount}/{booksPage.total} lu{booksPage.total !== 1 ? "s" : ""} + + + {/* Progress bar */} +
+
+
0 ? (booksReadCount / booksPage.total) * 100 : 0}%` }} + /> +
+
+
+ +
+ +
+
+
+ + {/* Books Grid */} + {books.length > 0 ? ( + <> + + + + ) : ( + + )} +
+ ); +} diff --git a/apps/backoffice/app/libraries/[id]/series/page.tsx b/apps/backoffice/app/libraries/[id]/series/page.tsx index 9738739..2d1ac22 100644 --- a/apps/backoffice/app/libraries/[id]/series/page.tsx +++ b/apps/backoffice/app/libraries/[id]/series/page.tsx @@ -1,5 +1,6 @@ import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api"; import { OffsetPagination } from "../../../components/ui"; +import { MarkSeriesReadButton } from "../../../components/MarkSeriesReadButton"; import Image from "next/image"; import Link from "next/link"; import { notFound } from "next/navigation"; @@ -48,12 +49,12 @@ export default async function LibrarySeriesPage({ <>
{series.map((s) => ( - -
+
= s.book_count ? "opacity-50" : ""}`}>
{s.name === "unclassified" ? "Unclassified" : s.name} -

- {s.book_count} book{s.book_count !== 1 ? 's' : ''} -

+
+

+ {s.books_read_count}/{s.book_count} lu{s.book_count !== 1 ? 's' : ''} +

+ +
diff --git a/apps/backoffice/app/series/page.tsx b/apps/backoffice/app/series/page.tsx new file mode 100644 index 0000000..05afc9a --- /dev/null +++ b/apps/backoffice/app/series/page.tsx @@ -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 ( + <> +
+

+ + + + Series +

+
+ + + + + + + + {/* Results count */} +

+ {seriesPage.total} series + {searchQuery && <> matching "{searchQuery}"} +

+ + {/* Series Grid */} + {series.length > 0 ? ( + <> +
+ {series.map((s) => ( + +
= s.book_count ? "opacity-50" : "" + }`} + > +
+ {`Cover +
+
+

+ {s.name === "unclassified" ? "Unclassified" : s.name} +

+
+

+ {s.books_read_count}/{s.book_count} lu{s.book_count !== 1 ? "s" : ""} +

+ +
+
+
+ + ))} +
+ + + + ) : ( +
+
+ + + +
+

+ {hasFilters ? "No series found matching your filters" : "No series available"} +

+
+ )} + + ); +} diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index f0f0469..a3658c6 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -112,6 +112,7 @@ export type SeriesDto = { book_count: number; books_read_count: number; first_book_id: string; + library_id: string; }; export function config() { @@ -263,10 +264,12 @@ export async function fetchBooks( series?: string, page: number = 1, limit: number = 50, + readingStatus?: string, ): Promise { const params = new URLSearchParams(); if (libraryId) params.set("library_id", libraryId); if (series) params.set("series", series); + if (readingStatus) params.set("reading_status", readingStatus); params.set("page", page.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 { + 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(`/series?${params.toString()}`); +} + export async function searchBooks( query: string, libraryId?: string, @@ -398,3 +418,10 @@ export async function updateReadingProgress( 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 }), + }); +}