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:
@@ -17,8 +17,8 @@ pub struct ListBooksQuery {
|
||||
pub series: Option<String>,
|
||||
#[schema(value_type = Option<String>, example = "unread,reading")]
|
||||
pub reading_status: Option<String>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub cursor: Option<Uuid>,
|
||||
#[schema(value_type = Option<i64>, example = 1)]
|
||||
pub page: Option<i64>,
|
||||
#[schema(value_type = Option<i64>, example = 50)]
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
@@ -49,9 +49,9 @@ pub struct BookItem {
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct BooksPage {
|
||||
pub items: Vec<BookItem>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub next_cursor: Option<Uuid>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -88,8 +88,8 @@ pub struct BookDetails {
|
||||
("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)"),
|
||||
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
|
||||
("cursor" = Option<String>, Query, description = "Cursor for pagination"),
|
||||
("limit" = Option<i64>, Query, description = "Max items to return (max 200)"),
|
||||
("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 = BooksPage),
|
||||
@@ -102,21 +102,23 @@ pub async fn list_books(
|
||||
Query(query): Query<ListBooksQuery>,
|
||||
) -> Result<Json<BooksPage>, 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;
|
||||
|
||||
// Parse reading_status CSV → Vec<String>
|
||||
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()
|
||||
});
|
||||
|
||||
// COUNT query: $1=library_id $2=kind, then optional series/reading_status
|
||||
let mut cp: usize = 2;
|
||||
let count_series_cond = match query.series.as_deref() {
|
||||
// Conditions partagées COUNT et DATA — $1=library_id $2=kind, puis optionnels
|
||||
let mut p: usize = 2;
|
||||
let series_cond = match query.series.as_deref() {
|
||||
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(),
|
||||
};
|
||||
let count_rs_cond = if reading_statuses.is_some() {
|
||||
cp += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${cp})")
|
||||
let rs_cond = if reading_statuses.is_some() {
|
||||
p += 1; format!("AND COALESCE(brp.status, 'unread') = ANY(${p})")
|
||||
} else { String::new() };
|
||||
|
||||
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
|
||||
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
||||
AND ($2::text IS NULL OR b.kind = $2)
|
||||
{count_series_cond}
|
||||
{count_rs_cond}"#
|
||||
{series_cond}
|
||||
{rs_cond}"#
|
||||
);
|
||||
|
||||
let mut count_builder = sqlx::query(&count_sql)
|
||||
.bind(query.library_id)
|
||||
.bind(query.kind.as_deref());
|
||||
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() };
|
||||
|
||||
// DATA: mêmes params filtre, puis $N+1=limit $N+2=offset
|
||||
let limit_p = p + 1;
|
||||
let offset_p = p + 2;
|
||||
let data_sql = format!(
|
||||
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,
|
||||
@@ -159,9 +143,8 @@ pub async fn list_books(
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
WHERE ($1::uuid IS NULL OR b.library_id = $1)
|
||||
AND ($2::text IS NULL OR b.kind = $2)
|
||||
AND ($3::uuid IS NULL OR b.id > $3)
|
||||
{series_condition}
|
||||
{reading_status_condition}
|
||||
{series_cond}
|
||||
{rs_cond}
|
||||
ORDER BY
|
||||
REGEXP_REPLACE(LOWER(b.title), '[0-9]+', '', 'g'),
|
||||
COALESCE(
|
||||
@@ -169,22 +152,30 @@ pub async fn list_books(
|
||||
0
|
||||
),
|
||||
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)
|
||||
.bind(query.library_id)
|
||||
.bind(query.kind.as_deref())
|
||||
.bind(query.cursor)
|
||||
.bind(limit + 1);
|
||||
.bind(query.kind.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 {
|
||||
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),
|
||||
@@ -193,7 +184,6 @@ pub async fn list_books(
|
||||
|
||||
let mut items: Vec<BookItem> = rows
|
||||
.iter()
|
||||
.take(limit as usize)
|
||||
.map(|row| {
|
||||
let thumbnail_path: Option<String> = row.get("thumbnail_path");
|
||||
BookItem {
|
||||
@@ -215,16 +205,11 @@ pub async fn list_books(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let next_cursor = if rows.len() > limit as usize {
|
||||
items.last().map(|b| b.id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Json(BooksPage {
|
||||
items: std::mem::take(&mut items),
|
||||
next_cursor,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -304,17 +289,19 @@ pub struct SeriesItem {
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SeriesPage {
|
||||
pub items: Vec<SeriesItem>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub next_cursor: Option<String>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ListSeriesQuery {
|
||||
#[schema(value_type = Option<String>, example = "dragon")]
|
||||
pub q: Option<String>,
|
||||
#[schema(value_type = Option<String>, example = "unread,reading")]
|
||||
pub reading_status: Option<String>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub cursor: Option<String>,
|
||||
#[schema(value_type = Option<i64>, example = 1)]
|
||||
pub page: Option<i64>,
|
||||
#[schema(value_type = Option<i64>, example = 50)]
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
@@ -326,9 +313,10 @@ pub struct ListSeriesQuery {
|
||||
tag = "books",
|
||||
params(
|
||||
("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')"),
|
||||
("cursor" = Option<String>, Query, description = "Cursor for pagination (series name)"),
|
||||
("limit" = Option<i64>, Query, description = "Max items to return (max 200)"),
|
||||
("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),
|
||||
@@ -342,26 +330,31 @@ pub async fn list_series(
|
||||
Query(query): Query<ListSeriesQuery>,
|
||||
) -> 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()
|
||||
});
|
||||
|
||||
// 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
|
||||
WHEN sc.books_read_count = sc.book_count THEN 'read'
|
||||
WHEN sc.books_read_count = 0 THEN 'unread'
|
||||
ELSE 'reading'
|
||||
END"#;
|
||||
|
||||
// COUNT query: $1=library_id, then optional reading_status ($2)
|
||||
let count_rs_cond = if reading_statuses.is_some() {
|
||||
format!("AND {series_status_expr} = ANY($2)")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
// Paramètres dynamiques — $1 = library_id fixe, puis optionnels dans l'ordre
|
||||
let mut p: usize = 1;
|
||||
|
||||
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!(
|
||||
r#"
|
||||
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
|
||||
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);
|
||||
if let Some(ref statuses) = reading_statuses {
|
||||
count_builder = count_builder.bind(statuses.clone());
|
||||
}
|
||||
|
||||
// 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()
|
||||
};
|
||||
// DATA: mêmes params dans le même ordre, puis limit/offset à la fin
|
||||
let limit_p = p + 1;
|
||||
let offset_p = p + 2;
|
||||
|
||||
let data_sql = format!(
|
||||
r#"
|
||||
@@ -424,8 +409,9 @@ pub async fn list_series(
|
||||
sb.id as first_book_id
|
||||
FROM series_counts sc
|
||||
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
||||
WHERE ($2::text IS NULL OR sc.name > $2)
|
||||
{data_rs_cond}
|
||||
WHERE TRUE
|
||||
{q_cond}
|
||||
{count_rs_cond}
|
||||
ORDER BY
|
||||
REGEXP_REPLACE(LOWER(sc.name), '[0-9]+', '', 'g'),
|
||||
COALESCE(
|
||||
@@ -433,18 +419,26 @@ pub async fn list_series(
|
||||
0
|
||||
),
|
||||
sc.name ASC
|
||||
LIMIT $3
|
||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
||||
"#
|
||||
);
|
||||
|
||||
let mut data_builder = sqlx::query(&data_sql)
|
||||
.bind(library_id)
|
||||
.bind(query.cursor.as_deref())
|
||||
.bind(limit + 1);
|
||||
let q_pattern = query.q.as_deref().map(|q| format!("%{}%", q));
|
||||
|
||||
let mut count_builder = sqlx::query(&count_sql).bind(library_id);
|
||||
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 {
|
||||
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),
|
||||
@@ -453,7 +447,6 @@ pub async fn list_series(
|
||||
|
||||
let mut items: Vec<SeriesItem> = rows
|
||||
.iter()
|
||||
.take(limit as usize)
|
||||
.map(|row| SeriesItem {
|
||||
name: row.get("name"),
|
||||
book_count: row.get("book_count"),
|
||||
@@ -462,16 +455,11 @@ pub async fn list_series(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let next_cursor = if rows.len() > limit as usize {
|
||||
items.last().map(|s| s.name.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Json(SeriesPage {
|
||||
items: std::mem::take(&mut items),
|
||||
total,
|
||||
next_cursor,
|
||||
page,
|
||||
limit,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api";
|
||||
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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -13,15 +13,15 @@ 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 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 [libraries] = await Promise.all([
|
||||
fetchLibraries().catch(() => [] as LibraryDto[])
|
||||
]);
|
||||
|
||||
let books: BookDto[] = [];
|
||||
let nextCursor: string | null = null;
|
||||
let total = 0;
|
||||
let searchResults: BookDto[] | null = null;
|
||||
let totalHits: number | null = null;
|
||||
|
||||
@@ -41,18 +41,22 @@ export default async function BooksPage({
|
||||
file_path: null,
|
||||
file_format: 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;
|
||||
}
|
||||
} else {
|
||||
const booksPage = await fetchBooks(libraryId, undefined, cursor, limit).catch(() => ({
|
||||
items: [] as BookDto[],
|
||||
next_cursor: null,
|
||||
prev_cursor: null
|
||||
const booksPage = await fetchBooks(libraryId, undefined, page, limit).catch(() => ({
|
||||
items: [] as BookDto[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit,
|
||||
}));
|
||||
books = booksPage.items;
|
||||
nextCursor = booksPage.next_cursor;
|
||||
total = booksPage.total;
|
||||
}
|
||||
|
||||
const displayBooks = (searchResults || books).map(book => ({
|
||||
@@ -60,8 +64,7 @@ export default async function BooksPage({
|
||||
coverUrl: getBookCoverUrl(book.id)
|
||||
}));
|
||||
|
||||
const hasNextPage = !!nextCursor;
|
||||
const hasPrevPage = !!cursor;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -142,12 +145,11 @@ export default async function BooksPage({
|
||||
<BooksGrid books={displayBooks} />
|
||||
|
||||
{!searchQuery && (
|
||||
<CursorPagination
|
||||
hasNextPage={hasNextPage}
|
||||
hasPrevPage={hasPrevPage}
|
||||
<OffsetPagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
pageSize={limit}
|
||||
currentCount={displayBooks.length}
|
||||
nextCursor={nextCursor}
|
||||
totalItems={total}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api";
|
||||
import { BooksGrid, EmptyState } from "../../../components/BookCard";
|
||||
import { LibrarySubPageHeader } from "../../../components/LibrarySubPageHeader";
|
||||
import { CursorPagination } from "../../../components/ui";
|
||||
import { OffsetPagination } from "../../../components/ui";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -15,15 +15,17 @@ export default async function LibraryBooksPage({
|
||||
}) {
|
||||
const { id } = await params;
|
||||
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 limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||
|
||||
|
||||
const [library, booksPage] = await Promise.all([
|
||||
fetchLibraries().then(libs => libs.find(l => l.id === id)),
|
||||
fetchBooks(id, series, cursor, limit).catch(() => ({
|
||||
items: [] as BookDto[],
|
||||
next_cursor: null
|
||||
fetchBooks(id, series, page, limit).catch(() => ({
|
||||
items: [] as BookDto[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit,
|
||||
}))
|
||||
]);
|
||||
|
||||
@@ -35,11 +37,9 @@ export default async function LibraryBooksPage({
|
||||
...book,
|
||||
coverUrl: getBookCoverUrl(book.id)
|
||||
}));
|
||||
const nextCursor = booksPage.next_cursor;
|
||||
|
||||
|
||||
const seriesDisplayName = series === "unclassified" ? "Unclassified" : series;
|
||||
const hasNextPage = !!nextCursor;
|
||||
const hasPrevPage = !!cursor;
|
||||
const totalPages = Math.ceil(booksPage.total / limit);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -63,12 +63,11 @@ export default async function LibraryBooksPage({
|
||||
<>
|
||||
<BooksGrid books={books} />
|
||||
|
||||
<CursorPagination
|
||||
hasNextPage={hasNextPage}
|
||||
hasPrevPage={hasPrevPage}
|
||||
<OffsetPagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
pageSize={limit}
|
||||
currentCount={books.length}
|
||||
nextCursor={nextCursor}
|
||||
totalItems={booksPage.total}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -16,12 +16,12 @@ export default async function LibrarySeriesPage({
|
||||
}) {
|
||||
const { id } = await params;
|
||||
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 [library, seriesPage] = await Promise.all([
|
||||
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) {
|
||||
@@ -29,9 +29,7 @@ export default async function LibrarySeriesPage({
|
||||
}
|
||||
|
||||
const series = seriesPage.items;
|
||||
const nextCursor = seriesPage.next_cursor;
|
||||
const hasNextPage = !!nextCursor;
|
||||
const hasPrevPage = !!cursor;
|
||||
const totalPages = Math.ceil(seriesPage.total / limit);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -78,12 +76,11 @@ export default async function LibrarySeriesPage({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CursorPagination
|
||||
hasNextPage={hasNextPage}
|
||||
hasPrevPage={hasPrevPage}
|
||||
<OffsetPagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
pageSize={limit}
|
||||
currentCount={series.length}
|
||||
nextCursor={nextCursor}
|
||||
totalItems={seriesPage.total}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -68,15 +68,16 @@ export type BookDto = {
|
||||
file_format: string | null;
|
||||
file_parse_status: string | null;
|
||||
updated_at: string;
|
||||
// Présents uniquement sur GET /books/:id (pas dans la liste)
|
||||
reading_status?: ReadingStatus;
|
||||
reading_current_page?: number | null;
|
||||
reading_last_read_at?: string | null;
|
||||
reading_status: ReadingStatus;
|
||||
reading_current_page: number | null;
|
||||
reading_last_read_at: string | null;
|
||||
};
|
||||
|
||||
export type BooksPageDto = {
|
||||
items: BookDto[];
|
||||
next_cursor: string | null;
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type SearchHitDto = {
|
||||
@@ -99,6 +100,7 @@ export type SearchResponseDto = {
|
||||
export type SeriesDto = {
|
||||
name: string;
|
||||
book_count: number;
|
||||
books_read_count: number;
|
||||
first_book_id: string;
|
||||
};
|
||||
|
||||
@@ -245,13 +247,13 @@ export async function revokeToken(id: string) {
|
||||
export async function fetchBooks(
|
||||
libraryId?: string,
|
||||
series?: string,
|
||||
cursor?: string,
|
||||
page: number = 1,
|
||||
limit: number = 50,
|
||||
): Promise<BooksPageDto> {
|
||||
const params = new URLSearchParams();
|
||||
if (libraryId) params.set("library_id", libraryId);
|
||||
if (series) params.set("series", series);
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
params.set("page", page.toString());
|
||||
params.set("limit", limit.toString());
|
||||
|
||||
return apiFetch<BooksPageDto>(`/books?${params.toString()}`);
|
||||
@@ -259,16 +261,18 @@ export async function fetchBooks(
|
||||
|
||||
export type SeriesPageDto = {
|
||||
items: SeriesDto[];
|
||||
next_cursor: string | null;
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export async function fetchSeries(
|
||||
libraryId: string,
|
||||
cursor?: string,
|
||||
page: number = 1,
|
||||
limit: number = 50,
|
||||
): Promise<SeriesPageDto> {
|
||||
const params = new URLSearchParams();
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
params.set("page", page.toString());
|
||||
params.set("limit", limit.toString());
|
||||
|
||||
return apiFetch<SeriesPageDto>(
|
||||
|
||||
Reference in New Issue
Block a user