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>,
#[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,
}))
}

View File

@@ -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}
/>
)}
</>

View File

@@ -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}
/>
</>
) : (

View File

@@ -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}
/>
</>
) : (

View File

@@ -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>(