feat(api+backoffice): pagination par page/offset + filtres séries

API:
- Remplace cursor par page (1-indexé) + OFFSET sur GET /books et GET /libraries/:id/series
- BooksPage et SeriesPage retournent total, page, limit
- GET /libraries/:id/series supporte ?q pour filtrer par nom (ILIKE)

Backoffice:
- Remplace CursorPagination par OffsetPagination sur les 3 pages de liste
- Adapte fetchBooks et fetchSeries (cursor → page)
- Met à jour les types BooksPageDto, SeriesPageDto, SeriesDto, BookDto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 11:06:34 +01:00
parent a2da5081ea
commit 8261050943
5 changed files with 136 additions and 146 deletions

View File

@@ -17,8 +17,8 @@ pub struct ListBooksQuery {
pub series: Option<String>, pub series: Option<String>,
#[schema(value_type = Option<String>, example = "unread,reading")] #[schema(value_type = Option<String>, example = "unread,reading")]
pub reading_status: Option<String>, pub reading_status: Option<String>,
#[schema(value_type = Option<String>)] #[schema(value_type = Option<i64>, example = 1)]
pub cursor: Option<Uuid>, pub page: Option<i64>,
#[schema(value_type = Option<i64>, example = 50)] #[schema(value_type = Option<i64>, example = 50)]
pub limit: Option<i64>, pub limit: Option<i64>,
} }
@@ -49,9 +49,9 @@ pub struct BookItem {
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct BooksPage { pub struct BooksPage {
pub items: Vec<BookItem>, pub items: Vec<BookItem>,
#[schema(value_type = Option<String>)]
pub next_cursor: Option<Uuid>,
pub total: i64, pub total: i64,
pub page: i64,
pub limit: i64,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
@@ -88,8 +88,8 @@ pub struct BookDetails {
("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf)"), ("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf)"),
("series" = Option<String>, Query, description = "Filter by series name (use 'unclassified' for books without series)"), ("series" = Option<String>, Query, description = "Filter by series name (use 'unclassified' for books without series)"),
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"), ("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
("cursor" = Option<String>, Query, description = "Cursor for pagination"), ("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
("limit" = Option<i64>, Query, description = "Max items to return (max 200)"), ("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
), ),
responses( responses(
(status = 200, body = BooksPage), (status = 200, body = BooksPage),
@@ -102,21 +102,23 @@ pub async fn list_books(
Query(query): Query<ListBooksQuery>, Query(query): Query<ListBooksQuery>,
) -> Result<Json<BooksPage>, ApiError> { ) -> Result<Json<BooksPage>, ApiError> {
let limit = query.limit.unwrap_or(50).clamp(1, 200); let limit = query.limit.unwrap_or(50).clamp(1, 200);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * limit;
// Parse reading_status CSV → Vec<String> // Parse reading_status CSV → Vec<String>
let reading_statuses: Option<Vec<String>> = query.reading_status.as_deref().map(|s| { 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() s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect()
}); });
// COUNT query: $1=library_id $2=kind, then optional series/reading_status // Conditions partagées COUNT et DATA — $1=library_id $2=kind, puis optionnels
let mut cp: usize = 2; let mut p: usize = 2;
let count_series_cond = match query.series.as_deref() { let series_cond = match query.series.as_deref() {
Some("unclassified") => "AND (b.series IS NULL OR b.series = '')".to_string(), Some("unclassified") => "AND (b.series IS NULL OR b.series = '')".to_string(),
Some(_) => { cp += 1; format!("AND b.series = ${cp}") } Some(_) => { p += 1; format!("AND b.series = ${p}") }
None => String::new(), None => String::new(),
}; };
let count_rs_cond = if reading_statuses.is_some() { let rs_cond = if reading_statuses.is_some() {
cp += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${cp})") p += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${p})")
} else { String::new() }; } else { String::new() };
let count_sql = format!( let count_sql = format!(
@@ -124,31 +126,13 @@ pub async fn list_books(
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
WHERE ($1::uuid IS NULL OR b.library_id = $1) WHERE ($1::uuid IS NULL OR b.library_id = $1)
AND ($2::text IS NULL OR b.kind = $2) AND ($2::text IS NULL OR b.kind = $2)
{count_series_cond} {series_cond}
{count_rs_cond}"# {rs_cond}"#
); );
let mut count_builder = sqlx::query(&count_sql) // DATA: mêmes params filtre, puis $N+1=limit $N+2=offset
.bind(query.library_id) let limit_p = p + 1;
.bind(query.kind.as_deref()); let offset_p = p + 2;
if let Some(s) = query.series.as_deref() {
if s != "unclassified" { count_builder = count_builder.bind(s); }
}
if let Some(ref statuses) = reading_statuses {
count_builder = count_builder.bind(statuses.clone());
}
// DATA query: $1=library_id $2=kind $3=cursor $4=limit, then optional series/reading_status
let mut dp: usize = 4;
let series_condition = match query.series.as_deref() {
Some("unclassified") => "AND (b.series IS NULL OR b.series = '')".to_string(),
Some(_) => { dp += 1; format!("AND b.series = ${dp}") }
None => String::new(),
};
let reading_status_condition = if reading_statuses.is_some() {
dp += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${dp})")
} else { String::new() };
let data_sql = format!( let data_sql = format!(
r#" 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, b.updated_at, SELECT b.id, b.library_id, b.kind, b.title, b.author, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.updated_at,
@@ -159,9 +143,8 @@ pub async fn list_books(
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
WHERE ($1::uuid IS NULL OR b.library_id = $1) WHERE ($1::uuid IS NULL OR b.library_id = $1)
AND ($2::text IS NULL OR b.kind = $2) AND ($2::text IS NULL OR b.kind = $2)
AND ($3::uuid IS NULL OR b.id > $3) {series_cond}
{series_condition} {rs_cond}
{reading_status_condition}
ORDER BY ORDER BY
REGEXP_REPLACE(LOWER(b.title), '[0-9]+', '', 'g'), REGEXP_REPLACE(LOWER(b.title), '[0-9]+', '', 'g'),
COALESCE( COALESCE(
@@ -169,22 +152,30 @@ pub async fn list_books(
0 0
), ),
b.title ASC b.title ASC
LIMIT $4 LIMIT ${limit_p} OFFSET ${offset_p}
"# "#
); );
let mut count_builder = sqlx::query(&count_sql)
.bind(query.library_id)
.bind(query.kind.as_deref());
let mut data_builder = sqlx::query(&data_sql) let mut data_builder = sqlx::query(&data_sql)
.bind(query.library_id) .bind(query.library_id)
.bind(query.kind.as_deref()) .bind(query.kind.as_deref());
.bind(query.cursor)
.bind(limit + 1);
if let Some(s) = query.series.as_deref() { if let Some(s) = query.series.as_deref() {
if s != "unclassified" { data_builder = data_builder.bind(s); } if s != "unclassified" {
count_builder = count_builder.bind(s);
data_builder = data_builder.bind(s);
}
} }
if let Some(ref statuses) = reading_statuses { 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(statuses.clone());
} }
data_builder = data_builder.bind(limit).bind(offset);
let (count_row, rows) = tokio::try_join!( let (count_row, rows) = tokio::try_join!(
count_builder.fetch_one(&state.pool), count_builder.fetch_one(&state.pool),
data_builder.fetch_all(&state.pool), data_builder.fetch_all(&state.pool),
@@ -193,7 +184,6 @@ pub async fn list_books(
let mut items: Vec<BookItem> = rows let mut items: Vec<BookItem> = rows
.iter() .iter()
.take(limit as usize)
.map(|row| { .map(|row| {
let thumbnail_path: Option<String> = row.get("thumbnail_path"); let thumbnail_path: Option<String> = row.get("thumbnail_path");
BookItem { BookItem {
@@ -215,16 +205,11 @@ pub async fn list_books(
}) })
.collect(); .collect();
let next_cursor = if rows.len() > limit as usize {
items.last().map(|b| b.id)
} else {
None
};
Ok(Json(BooksPage { Ok(Json(BooksPage {
items: std::mem::take(&mut items), items: std::mem::take(&mut items),
next_cursor,
total, total,
page,
limit,
})) }))
} }
@@ -304,17 +289,19 @@ pub struct SeriesItem {
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct SeriesPage { pub struct SeriesPage {
pub items: Vec<SeriesItem>, pub items: Vec<SeriesItem>,
#[schema(value_type = Option<String>)]
pub next_cursor: Option<String>,
pub total: i64, pub total: i64,
pub page: i64,
pub limit: i64,
} }
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
pub struct ListSeriesQuery { pub struct ListSeriesQuery {
#[schema(value_type = Option<String>, example = "dragon")]
pub q: Option<String>,
#[schema(value_type = Option<String>, example = "unread,reading")] #[schema(value_type = Option<String>, example = "unread,reading")]
pub reading_status: Option<String>, pub reading_status: Option<String>,
#[schema(value_type = Option<String>)] #[schema(value_type = Option<i64>, example = 1)]
pub cursor: Option<String>, pub page: Option<i64>,
#[schema(value_type = Option<i64>, example = 50)] #[schema(value_type = Option<i64>, example = 50)]
pub limit: Option<i64>, pub limit: Option<i64>,
} }
@@ -326,9 +313,10 @@ pub struct ListSeriesQuery {
tag = "books", tag = "books",
params( params(
("library_id" = String, Path, description = "Library UUID"), ("library_id" = String, Path, description = "Library UUID"),
("q" = Option<String>, Query, description = "Filter by series name (case-insensitive, partial match)"),
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"), ("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
("cursor" = Option<String>, Query, description = "Cursor for pagination (series name)"), ("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
("limit" = Option<i64>, Query, description = "Max items to return (max 200)"), ("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
), ),
responses( responses(
(status = 200, body = SeriesPage), (status = 200, body = SeriesPage),
@@ -342,26 +330,31 @@ pub async fn list_series(
Query(query): Query<ListSeriesQuery>, Query(query): Query<ListSeriesQuery>,
) -> Result<Json<SeriesPage>, ApiError> { ) -> Result<Json<SeriesPage>, ApiError> {
let limit = query.limit.unwrap_or(50).clamp(1, 200); 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| { 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() s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect()
}); });
// Le CTE series_counts est partagé — on dérive le statut de lecture de la série
// à partir de books_read_count / book_count.
let series_status_expr = r#"CASE let series_status_expr = r#"CASE
WHEN sc.books_read_count = sc.book_count THEN 'read' WHEN sc.books_read_count = sc.book_count THEN 'read'
WHEN sc.books_read_count = 0 THEN 'unread' WHEN sc.books_read_count = 0 THEN 'unread'
ELSE 'reading' ELSE 'reading'
END"#; END"#;
// COUNT query: $1=library_id, then optional reading_status ($2) // Paramètres dynamiques — $1 = library_id fixe, puis optionnels dans l'ordre
let count_rs_cond = if reading_statuses.is_some() { let mut p: usize = 1;
format!("AND {series_status_expr} = ANY($2)")
} else {
String::new()
};
let q_cond = if query.q.is_some() {
p += 1; format!("AND sc.name ILIKE ${p}")
} else { String::new() };
let count_rs_cond = if reading_statuses.is_some() {
p += 1; format!("AND {series_status_expr} = ANY(${p})")
} else { String::new() };
// q_cond et count_rs_cond partagent le même p — le count_sql les réutilise directement
let count_sql = format!( let count_sql = format!(
r#" r#"
WITH sorted_books AS ( WITH sorted_books AS (
@@ -376,21 +369,13 @@ pub async fn list_series(
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
GROUP BY sb.name GROUP BY sb.name
) )
SELECT COUNT(*) FROM series_counts sc WHERE TRUE {count_rs_cond} SELECT COUNT(*) FROM series_counts sc WHERE TRUE {q_cond} {count_rs_cond}
"# "#
); );
let mut count_builder = sqlx::query(&count_sql).bind(library_id); // DATA: mêmes params dans le même ordre, puis limit/offset à la fin
if let Some(ref statuses) = reading_statuses { let limit_p = p + 1;
count_builder = count_builder.bind(statuses.clone()); let offset_p = p + 2;
}
// DATA query: $1=library_id $2=cursor $3=limit, then optional reading_status ($4)
let data_rs_cond = if reading_statuses.is_some() {
format!("AND {series_status_expr} = ANY($4)")
} else {
String::new()
};
let data_sql = format!( let data_sql = format!(
r#" r#"
@@ -424,8 +409,9 @@ pub async fn list_series(
sb.id as first_book_id sb.id as first_book_id
FROM series_counts sc FROM series_counts sc
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1 JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
WHERE ($2::text IS NULL OR sc.name > $2) WHERE TRUE
{data_rs_cond} {q_cond}
{count_rs_cond}
ORDER BY ORDER BY
REGEXP_REPLACE(LOWER(sc.name), '[0-9]+', '', 'g'), REGEXP_REPLACE(LOWER(sc.name), '[0-9]+', '', 'g'),
COALESCE( COALESCE(
@@ -433,18 +419,26 @@ pub async fn list_series(
0 0
), ),
sc.name ASC sc.name ASC
LIMIT $3 LIMIT ${limit_p} OFFSET ${offset_p}
"# "#
); );
let mut data_builder = sqlx::query(&data_sql) let q_pattern = query.q.as_deref().map(|q| format!("%{}%", q));
.bind(library_id)
.bind(query.cursor.as_deref()) let mut count_builder = sqlx::query(&count_sql).bind(library_id);
.bind(limit + 1); let mut data_builder = sqlx::query(&data_sql).bind(library_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 { 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(statuses.clone());
} }
data_builder = data_builder.bind(limit).bind(offset);
let (count_row, rows) = tokio::try_join!( let (count_row, rows) = tokio::try_join!(
count_builder.fetch_one(&state.pool), count_builder.fetch_one(&state.pool),
data_builder.fetch_all(&state.pool), data_builder.fetch_all(&state.pool),
@@ -453,7 +447,6 @@ pub async fn list_series(
let mut items: Vec<SeriesItem> = rows let mut items: Vec<SeriesItem> = rows
.iter() .iter()
.take(limit as usize)
.map(|row| SeriesItem { .map(|row| SeriesItem {
name: row.get("name"), name: row.get("name"),
book_count: row.get("book_count"), book_count: row.get("book_count"),
@@ -462,16 +455,11 @@ pub async fn list_series(
}) })
.collect(); .collect();
let next_cursor = if rows.len() > limit as usize {
items.last().map(|s| s.name.clone())
} else {
None
};
Ok(Json(SeriesPage { Ok(Json(SeriesPage {
items: std::mem::take(&mut items), items: std::mem::take(&mut items),
total, total,
next_cursor, page,
limit,
})) }))
} }

View File

@@ -1,6 +1,6 @@
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api"; import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api";
import { BooksGrid, EmptyState } from "../components/BookCard"; import { BooksGrid, EmptyState } from "../components/BookCard";
import { Card, CardContent, Button, FormField, FormInput, FormSelect, FormRow, CursorPagination } from "../components/ui"; import { Card, CardContent, Button, FormField, FormInput, FormSelect, FormRow, OffsetPagination } from "../components/ui";
import Link from "next/link"; import Link from "next/link";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -13,7 +13,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 cursor = typeof searchParamsAwaited.cursor === "string" ? searchParamsAwaited.cursor : undefined; 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;
const [libraries] = await Promise.all([ const [libraries] = await Promise.all([
@@ -21,7 +21,7 @@ export default async function BooksPage({
]); ]);
let books: BookDto[] = []; let books: BookDto[] = [];
let nextCursor: string | null = null; let total = 0;
let searchResults: BookDto[] | null = null; let searchResults: BookDto[] | null = null;
let totalHits: number | null = null; let totalHits: number | null = null;
@@ -41,18 +41,22 @@ export default async function BooksPage({
file_path: null, file_path: null,
file_format: null, file_format: null,
file_parse_status: null, file_parse_status: null,
updated_at: "" updated_at: "",
reading_status: "unread" as const,
reading_current_page: null,
reading_last_read_at: null,
})); }));
totalHits = searchResponse.estimated_total_hits; totalHits = searchResponse.estimated_total_hits;
} }
} else { } else {
const booksPage = await fetchBooks(libraryId, undefined, cursor, limit).catch(() => ({ const booksPage = await fetchBooks(libraryId, undefined, page, limit).catch(() => ({
items: [] as BookDto[], items: [] as BookDto[],
next_cursor: null, total: 0,
prev_cursor: null page: 1,
limit,
})); }));
books = booksPage.items; books = booksPage.items;
nextCursor = booksPage.next_cursor; total = booksPage.total;
} }
const displayBooks = (searchResults || books).map(book => ({ const displayBooks = (searchResults || books).map(book => ({
@@ -60,8 +64,7 @@ export default async function BooksPage({
coverUrl: getBookCoverUrl(book.id) coverUrl: getBookCoverUrl(book.id)
})); }));
const hasNextPage = !!nextCursor; const totalPages = Math.ceil(total / limit);
const hasPrevPage = !!cursor;
return ( return (
<> <>
@@ -142,12 +145,11 @@ export default async function BooksPage({
<BooksGrid books={displayBooks} /> <BooksGrid books={displayBooks} />
{!searchQuery && ( {!searchQuery && (
<CursorPagination <OffsetPagination
hasNextPage={hasNextPage} currentPage={page}
hasPrevPage={hasPrevPage} totalPages={totalPages}
pageSize={limit} pageSize={limit}
currentCount={displayBooks.length} totalItems={total}
nextCursor={nextCursor}
/> />
)} )}
</> </>

View File

@@ -1,7 +1,7 @@
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api"; import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api";
import { BooksGrid, EmptyState } from "../../../components/BookCard"; import { BooksGrid, EmptyState } from "../../../components/BookCard";
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader"; import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
import { CursorPagination } from "../../../components/ui"; import { OffsetPagination } from "../../../components/ui";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -15,15 +15,17 @@ export default async function LibraryBooksPage({
}) { }) {
const { id } = await params; const { id } = await params;
const searchParamsAwaited = await searchParams; const searchParamsAwaited = await searchParams;
const cursor = typeof searchParamsAwaited.cursor === "string" ? searchParamsAwaited.cursor : undefined; const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
const series = typeof searchParamsAwaited.series === "string" ? searchParamsAwaited.series : undefined; const series = typeof searchParamsAwaited.series === "string" ? searchParamsAwaited.series : undefined;
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20; const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
const [library, booksPage] = await Promise.all([ const [library, booksPage] = await Promise.all([
fetchLibraries().then(libs => libs.find(l => l.id === id)), fetchLibraries().then(libs => libs.find(l => l.id === id)),
fetchBooks(id, series, cursor, limit).catch(() => ({ fetchBooks(id, series, page, limit).catch(() => ({
items: [] as BookDto[], items: [] as BookDto[],
next_cursor: null total: 0,
page: 1,
limit,
})) }))
]); ]);
@@ -35,11 +37,9 @@ export default async function LibraryBooksPage({
...book, ...book,
coverUrl: getBookCoverUrl(book.id) coverUrl: getBookCoverUrl(book.id)
})); }));
const nextCursor = booksPage.next_cursor;
const seriesDisplayName = series === "unclassified" ? "Unclassified" : series; const seriesDisplayName = series === "unclassified" ? "Unclassified" : series;
const hasNextPage = !!nextCursor; const totalPages = Math.ceil(booksPage.total / limit);
const hasPrevPage = !!cursor;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -63,12 +63,11 @@ export default async function LibraryBooksPage({
<> <>
<BooksGrid books={books} /> <BooksGrid books={books} />
<CursorPagination <OffsetPagination
hasNextPage={hasNextPage} currentPage={page}
hasPrevPage={hasPrevPage} totalPages={totalPages}
pageSize={limit} pageSize={limit}
currentCount={books.length} totalItems={booksPage.total}
nextCursor={nextCursor}
/> />
</> </>
) : ( ) : (

View File

@@ -1,5 +1,5 @@
import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api"; import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto, SeriesPageDto } from "../../../../lib/api";
import { CursorPagination } from "../../../components/ui"; import { OffsetPagination } from "../../../components/ui";
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";
@@ -16,12 +16,12 @@ export default async function LibrarySeriesPage({
}) { }) {
const { id } = await params; const { id } = await params;
const searchParamsAwaited = await searchParams; const searchParamsAwaited = await searchParams;
const cursor = typeof searchParamsAwaited.cursor === "string" ? searchParamsAwaited.cursor : undefined; 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;
const [library, seriesPage] = await Promise.all([ const [library, seriesPage] = await Promise.all([
fetchLibraries().then(libs => libs.find(l => l.id === id)), fetchLibraries().then(libs => libs.find(l => l.id === id)),
fetchSeries(id, cursor, limit).catch(() => ({ items: [] as SeriesDto[], next_cursor: null }) as SeriesPageDto) fetchSeries(id, page, limit).catch(() => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto)
]); ]);
if (!library) { if (!library) {
@@ -29,9 +29,7 @@ export default async function LibrarySeriesPage({
} }
const series = seriesPage.items; const series = seriesPage.items;
const nextCursor = seriesPage.next_cursor; const totalPages = Math.ceil(seriesPage.total / limit);
const hasNextPage = !!nextCursor;
const hasPrevPage = !!cursor;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -78,12 +76,11 @@ export default async function LibrarySeriesPage({
))} ))}
</div> </div>
<CursorPagination <OffsetPagination
hasNextPage={hasNextPage} currentPage={page}
hasPrevPage={hasPrevPage} totalPages={totalPages}
pageSize={limit} pageSize={limit}
currentCount={series.length} totalItems={seriesPage.total}
nextCursor={nextCursor}
/> />
</> </>
) : ( ) : (

View File

@@ -68,15 +68,16 @@ export type BookDto = {
file_format: string | null; file_format: string | null;
file_parse_status: string | null; file_parse_status: string | null;
updated_at: string; updated_at: string;
// Présents uniquement sur GET /books/:id (pas dans la liste) reading_status: ReadingStatus;
reading_status?: ReadingStatus; reading_current_page: number | null;
reading_current_page?: number | null; reading_last_read_at: string | null;
reading_last_read_at?: string | null;
}; };
export type BooksPageDto = { export type BooksPageDto = {
items: BookDto[]; items: BookDto[];
next_cursor: string | null; total: number;
page: number;
limit: number;
}; };
export type SearchHitDto = { export type SearchHitDto = {
@@ -99,6 +100,7 @@ export type SearchResponseDto = {
export type SeriesDto = { export type SeriesDto = {
name: string; name: string;
book_count: number; book_count: number;
books_read_count: number;
first_book_id: string; first_book_id: string;
}; };
@@ -245,13 +247,13 @@ export async function revokeToken(id: string) {
export async function fetchBooks( export async function fetchBooks(
libraryId?: string, libraryId?: string,
series?: string, series?: string,
cursor?: string, page: number = 1,
limit: number = 50, limit: number = 50,
): 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 (cursor) params.set("cursor", cursor); params.set("page", page.toString());
params.set("limit", limit.toString()); params.set("limit", limit.toString());
return apiFetch<BooksPageDto>(`/books?${params.toString()}`); return apiFetch<BooksPageDto>(`/books?${params.toString()}`);
@@ -259,16 +261,18 @@ export async function fetchBooks(
export type SeriesPageDto = { export type SeriesPageDto = {
items: SeriesDto[]; items: SeriesDto[];
next_cursor: string | null; total: number;
page: number;
limit: number;
}; };
export async function fetchSeries( export async function fetchSeries(
libraryId: string, libraryId: string,
cursor?: string, page: number = 1,
limit: number = 50, limit: number = 50,
): Promise<SeriesPageDto> { ): Promise<SeriesPageDto> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (cursor) params.set("cursor", cursor); params.set("page", page.toString());
params.set("limit", limit.toString()); params.set("limit", limit.toString());
return apiFetch<SeriesPageDto>( return apiFetch<SeriesPageDto>(