feat: add sort parameter (title/latest) to books and series endpoints
Add sort=latest option to GET /books and GET /series API endpoints, and expose a Sort select in the backoffice books and series pages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,9 @@ pub struct ListBooksQuery {
|
|||||||
pub page: Option<i64>,
|
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>,
|
||||||
|
/// Sort order: "title" (default) or "latest" (most recently added first)
|
||||||
|
#[schema(value_type = Option<String>, example = "latest")]
|
||||||
|
pub sort: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -93,6 +96,7 @@ pub struct BookDetails {
|
|||||||
("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')"),
|
||||||
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
||||||
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
||||||
|
("sort" = Option<String>, Query, description = "Sort order: 'title' (default) or 'latest' (most recently added first)"),
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = BooksPage),
|
(status = 200, body = BooksPage),
|
||||||
@@ -134,6 +138,12 @@ pub async fn list_books(
|
|||||||
{rs_cond}"#
|
{rs_cond}"#
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let order_clause = if query.sort.as_deref() == Some("latest") {
|
||||||
|
"b.updated_at DESC".to_string()
|
||||||
|
} else {
|
||||||
|
"REGEXP_REPLACE(LOWER(b.title), '[0-9]+', '', 'g'), COALESCE((REGEXP_MATCH(LOWER(b.title), '\\d+'))[1]::int, 0), b.title ASC".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
// DATA: mêmes params filtre, puis $N+1=limit $N+2=offset
|
// DATA: mêmes params filtre, puis $N+1=limit $N+2=offset
|
||||||
let limit_p = p + 1;
|
let limit_p = p + 1;
|
||||||
let offset_p = p + 2;
|
let offset_p = p + 2;
|
||||||
@@ -150,13 +160,7 @@ pub async fn list_books(
|
|||||||
AND ($3::text IS NULL OR b.format = $3)
|
AND ($3::text IS NULL OR b.format = $3)
|
||||||
{series_cond}
|
{series_cond}
|
||||||
{rs_cond}
|
{rs_cond}
|
||||||
ORDER BY
|
ORDER BY {order_clause}
|
||||||
REGEXP_REPLACE(LOWER(b.title), '[0-9]+', '', 'g'),
|
|
||||||
COALESCE(
|
|
||||||
(REGEXP_MATCH(LOWER(b.title), '\d+'))[1]::int,
|
|
||||||
0
|
|
||||||
),
|
|
||||||
b.title ASC
|
|
||||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
LIMIT ${limit_p} OFFSET ${offset_p}
|
||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
@@ -486,6 +490,9 @@ pub struct ListAllSeriesQuery {
|
|||||||
pub page: Option<i64>,
|
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>,
|
||||||
|
/// Sort order: "title" (default) or "latest" (most recently added first)
|
||||||
|
#[schema(value_type = Option<String>, example = "latest")]
|
||||||
|
pub sort: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all series across libraries with optional filtering and pagination
|
/// List all series across libraries with optional filtering and pagination
|
||||||
@@ -499,6 +506,7 @@ pub struct ListAllSeriesQuery {
|
|||||||
("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')"),
|
||||||
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
||||||
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
||||||
|
("sort" = Option<String>, Query, description = "Sort order: 'title' (default) or 'latest' (most recently added first)"),
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = SeriesPage),
|
(status = 200, body = SeriesPage),
|
||||||
@@ -558,6 +566,12 @@ pub async fn list_all_series(
|
|||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let series_order_clause = if query.sort.as_deref() == Some("latest") {
|
||||||
|
"sc.latest_updated_at DESC".to_string()
|
||||||
|
} else {
|
||||||
|
"REGEXP_REPLACE(LOWER(sc.name), '[0-9]+', '', 'g'), COALESCE((REGEXP_MATCH(LOWER(sc.name), '\\d+'))[1]::int, 0), sc.name ASC".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
let limit_p = p + 1;
|
let limit_p = p + 1;
|
||||||
let offset_p = p + 2;
|
let offset_p = p + 2;
|
||||||
|
|
||||||
@@ -568,6 +582,7 @@ pub async fn list_all_series(
|
|||||||
COALESCE(NULLIF(series, ''), 'unclassified') as name,
|
COALESCE(NULLIF(series, ''), 'unclassified') as name,
|
||||||
id,
|
id,
|
||||||
library_id,
|
library_id,
|
||||||
|
updated_at,
|
||||||
ROW_NUMBER() OVER (
|
ROW_NUMBER() OVER (
|
||||||
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@@ -582,7 +597,8 @@ pub async fn list_all_series(
|
|||||||
SELECT
|
SELECT
|
||||||
sb.name,
|
sb.name,
|
||||||
COUNT(*) as book_count,
|
COUNT(*) as book_count,
|
||||||
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count
|
COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count,
|
||||||
|
MAX(sb.updated_at) as latest_updated_at
|
||||||
FROM sorted_books sb
|
FROM sorted_books sb
|
||||||
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
|
||||||
@@ -598,13 +614,7 @@ pub async fn list_all_series(
|
|||||||
WHERE TRUE
|
WHERE TRUE
|
||||||
{q_cond}
|
{q_cond}
|
||||||
{rs_cond}
|
{rs_cond}
|
||||||
ORDER BY
|
ORDER BY {series_order_clause}
|
||||||
REGEXP_REPLACE(LOWER(sc.name), '[0-9]+', '', 'g'),
|
|
||||||
COALESCE(
|
|
||||||
(REGEXP_MATCH(LOWER(sc.name), '\d+'))[1]::int,
|
|
||||||
0
|
|
||||||
),
|
|
||||||
sc.name ASC
|
|
||||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
LIMIT ${limit_p} OFFSET ${offset_p}
|
||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default async function BooksPage({
|
|||||||
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 readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
||||||
|
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
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;
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ export default async function BooksPage({
|
|||||||
totalHits = searchResponse.estimated_total_hits;
|
totalHits = searchResponse.estimated_total_hits;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus).catch(() => ({
|
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort).catch(() => ({
|
||||||
items: [] as BookDto[],
|
items: [] as BookDto[],
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -83,7 +84,12 @@ export default async function BooksPage({
|
|||||||
{ value: "read", label: "Read" },
|
{ value: "read", label: "Read" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasFilters = searchQuery || libraryId || readingStatus;
|
const sortOptions = [
|
||||||
|
{ value: "", label: "Title" },
|
||||||
|
{ value: "latest", label: "Latest added" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasFilters = searchQuery || libraryId || readingStatus || sort;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -104,6 +110,7 @@ export default async function BooksPage({
|
|||||||
{ name: "q", type: "text", label: "Search", placeholder: "Search by title, author, series...", className: "flex-1 w-full" },
|
{ name: "q", type: "text", label: "Search", placeholder: "Search by title, author, series...", className: "flex-1 w-full" },
|
||||||
{ name: "library", type: "select", label: "Library", options: libraryOptions, className: "w-full sm:w-48" },
|
{ name: "library", type: "select", label: "Library", options: libraryOptions, className: "w-full sm:w-48" },
|
||||||
{ name: "status", type: "select", label: "Status", options: statusOptions, className: "w-full sm:w-40" },
|
{ name: "status", type: "select", label: "Status", options: statusOptions, className: "w-full sm:w-40" },
|
||||||
|
{ name: "sort", type: "select", label: "Sort", options: sortOptions, className: "w-full sm:w-40" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -16,19 +16,25 @@ export default async function SeriesPage({
|
|||||||
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 readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
||||||
|
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
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, seriesPage] = await Promise.all([
|
const [libraries, seriesPage] = await Promise.all([
|
||||||
fetchLibraries().catch(() => [] as LibraryDto[]),
|
fetchLibraries().catch(() => [] as LibraryDto[]),
|
||||||
fetchAllSeries(libraryId, searchQuery || undefined, readingStatus, page, limit).catch(
|
fetchAllSeries(libraryId, searchQuery || undefined, readingStatus, page, limit, sort).catch(
|
||||||
() => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto
|
() => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const series = seriesPage.items;
|
const series = seriesPage.items;
|
||||||
const totalPages = Math.ceil(seriesPage.total / limit);
|
const totalPages = Math.ceil(seriesPage.total / limit);
|
||||||
const hasFilters = searchQuery || libraryId || readingStatus;
|
const sortOptions = [
|
||||||
|
{ value: "", label: "Title" },
|
||||||
|
{ value: "latest", label: "Latest added" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasFilters = searchQuery || libraryId || readingStatus || sort;
|
||||||
|
|
||||||
const libraryOptions = [
|
const libraryOptions = [
|
||||||
{ value: "", label: "All libraries" },
|
{ value: "", label: "All libraries" },
|
||||||
@@ -61,6 +67,7 @@ export default async function SeriesPage({
|
|||||||
{ name: "q", type: "text", label: "Search", placeholder: "Search by series name...", className: "flex-1 w-full" },
|
{ name: "q", type: "text", label: "Search", placeholder: "Search by series name...", className: "flex-1 w-full" },
|
||||||
{ name: "library", type: "select", label: "Library", options: libraryOptions, className: "w-full sm:w-48" },
|
{ name: "library", type: "select", label: "Library", options: libraryOptions, className: "w-full sm:w-48" },
|
||||||
{ name: "status", type: "select", label: "Status", options: statusOptions, className: "w-full sm:w-40" },
|
{ name: "status", type: "select", label: "Status", options: statusOptions, className: "w-full sm:w-40" },
|
||||||
|
{ name: "sort", type: "select", label: "Sort", options: sortOptions, className: "w-full sm:w-40" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -265,11 +265,13 @@ export async function fetchBooks(
|
|||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 50,
|
limit: number = 50,
|
||||||
readingStatus?: string,
|
readingStatus?: string,
|
||||||
|
sort?: string,
|
||||||
): 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 (readingStatus) params.set("reading_status", readingStatus);
|
if (readingStatus) params.set("reading_status", readingStatus);
|
||||||
|
if (sort) params.set("sort", sort);
|
||||||
params.set("page", page.toString());
|
params.set("page", page.toString());
|
||||||
params.set("limit", limit.toString());
|
params.set("limit", limit.toString());
|
||||||
|
|
||||||
@@ -303,11 +305,13 @@ export async function fetchAllSeries(
|
|||||||
readingStatus?: string,
|
readingStatus?: string,
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 50,
|
limit: number = 50,
|
||||||
|
sort?: string,
|
||||||
): Promise<SeriesPageDto> {
|
): Promise<SeriesPageDto> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (libraryId) params.set("library_id", libraryId);
|
if (libraryId) params.set("library_id", libraryId);
|
||||||
if (q) params.set("q", q);
|
if (q) params.set("q", q);
|
||||||
if (readingStatus) params.set("reading_status", readingStatus);
|
if (readingStatus) params.set("reading_status", readingStatus);
|
||||||
|
if (sort) params.set("sort", sort);
|
||||||
params.set("page", page.toString());
|
params.set("page", page.toString());
|
||||||
params.set("limit", limit.toString());
|
params.set("limit", limit.toString());
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user