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>,
|
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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
Reference in New Issue
Block a user