feat: unifier la recherche livres via le endpoint /books avec paramètre q
La recherche utilise désormais le endpoint paginé /books avec un filtre ILIKE sur title/series/author, ce qui permet la pagination des résultats. Les series_hits sont toujours récupérés en parallèle via searchBooks. Corrige aussi le remount du LiveSearchForm lors de la navigation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,9 @@ use crate::{auth::AuthUser, error::ApiError, index_jobs::IndexJobResponse, state
|
|||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct ListBooksQuery {
|
pub struct ListBooksQuery {
|
||||||
|
/// Text search on title, series and author (case-insensitive, partial match)
|
||||||
|
#[schema(value_type = Option<String>, example = "dragon")]
|
||||||
|
pub q: Option<String>,
|
||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
pub library_id: Option<Uuid>,
|
pub library_id: Option<Uuid>,
|
||||||
#[schema(value_type = Option<String>)]
|
#[schema(value_type = Option<String>)]
|
||||||
@@ -104,6 +107,7 @@ pub struct BookDetails {
|
|||||||
path = "/books",
|
path = "/books",
|
||||||
tag = "books",
|
tag = "books",
|
||||||
params(
|
params(
|
||||||
|
("q" = Option<String>, Query, description = "Text search on title, series and author (case-insensitive, partial match)"),
|
||||||
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
||||||
("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf, epub)"),
|
("kind" = Option<String>, Query, description = "Filter by book kind (cbz, cbr, pdf, epub)"),
|
||||||
("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)"),
|
||||||
@@ -153,6 +157,9 @@ pub async fn list_books(
|
|||||||
Some(_) => { p += 1; format!("AND eml.provider = ${p}") },
|
Some(_) => { p += 1; format!("AND eml.provider = ${p}") },
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
};
|
};
|
||||||
|
let q_cond = if query.q.is_some() {
|
||||||
|
p += 1; format!("AND (b.title ILIKE ${p} OR b.series ILIKE ${p} OR b.author ILIKE ${p})")
|
||||||
|
} else { String::new() };
|
||||||
p += 1;
|
p += 1;
|
||||||
let uid_p = p;
|
let uid_p = p;
|
||||||
|
|
||||||
@@ -176,7 +183,8 @@ pub async fn list_books(
|
|||||||
{series_cond}
|
{series_cond}
|
||||||
{rs_cond}
|
{rs_cond}
|
||||||
{author_cond}
|
{author_cond}
|
||||||
{metadata_cond}"#
|
{metadata_cond}
|
||||||
|
{q_cond}"#
|
||||||
);
|
);
|
||||||
|
|
||||||
let order_clause = if query.sort.as_deref() == Some("latest") {
|
let order_clause = if query.sort.as_deref() == Some("latest") {
|
||||||
@@ -205,6 +213,7 @@ pub async fn list_books(
|
|||||||
{rs_cond}
|
{rs_cond}
|
||||||
{author_cond}
|
{author_cond}
|
||||||
{metadata_cond}
|
{metadata_cond}
|
||||||
|
{q_cond}
|
||||||
ORDER BY {order_clause}
|
ORDER BY {order_clause}
|
||||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
LIMIT ${limit_p} OFFSET ${offset_p}
|
||||||
"#
|
"#
|
||||||
@@ -239,6 +248,11 @@ pub async fn list_books(
|
|||||||
data_builder = data_builder.bind(mp.clone());
|
data_builder = data_builder.bind(mp.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(ref q) = query.q {
|
||||||
|
let pattern = format!("%{q}%");
|
||||||
|
count_builder = count_builder.bind(pattern.clone());
|
||||||
|
data_builder = data_builder.bind(pattern);
|
||||||
|
}
|
||||||
count_builder = count_builder.bind(user_id);
|
count_builder = count_builder.bind(user_id);
|
||||||
data_builder = data_builder.bind(user_id).bind(limit).bind(offset);
|
data_builder = data_builder.bind(user_id).bind(limit).bind(offset);
|
||||||
|
|
||||||
|
|||||||
@@ -28,53 +28,27 @@ export default async function BooksPage({
|
|||||||
fetchLibraries().catch(() => [] as LibraryDto[])
|
fetchLibraries().catch(() => [] as LibraryDto[])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let books: BookDto[] = [];
|
|
||||||
let total = 0;
|
|
||||||
let searchResults: BookDto[] | null = null;
|
|
||||||
let seriesHits: SeriesHitDto[] = [];
|
let seriesHits: SeriesHitDto[] = [];
|
||||||
let totalHits: number | null = null;
|
|
||||||
|
|
||||||
if (searchQuery) {
|
const [booksPage, searchResponse] = await Promise.all([
|
||||||
const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null);
|
fetchBooks(libraryId, undefined, page, limit, readingStatus, sort, undefined, format, metadataProvider, searchQuery || undefined).catch(() => ({
|
||||||
if (searchResponse) {
|
|
||||||
seriesHits = searchResponse.series_hits ?? [];
|
|
||||||
searchResults = searchResponse.hits.map(hit => ({
|
|
||||||
id: hit.id,
|
|
||||||
library_id: hit.library_id,
|
|
||||||
kind: hit.kind,
|
|
||||||
title: hit.title,
|
|
||||||
author: hit.authors?.[0] ?? null,
|
|
||||||
authors: hit.authors ?? [],
|
|
||||||
series: hit.series,
|
|
||||||
volume: hit.volume,
|
|
||||||
language: hit.language,
|
|
||||||
page_count: null,
|
|
||||||
format: null,
|
|
||||||
file_path: null,
|
|
||||||
file_format: null,
|
|
||||||
file_parse_status: null,
|
|
||||||
updated_at: "",
|
|
||||||
reading_status: "unread" as const,
|
|
||||||
reading_current_page: null,
|
|
||||||
reading_last_read_at: null,
|
|
||||||
summary: null,
|
|
||||||
isbn: null,
|
|
||||||
publish_date: null,
|
|
||||||
}));
|
|
||||||
totalHits = searchResponse.estimated_total_hits;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort, undefined, format, metadataProvider).catch(() => ({
|
|
||||||
items: [] as BookDto[],
|
items: [] as BookDto[],
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
limit,
|
limit,
|
||||||
}));
|
})),
|
||||||
books = booksPage.items;
|
searchQuery
|
||||||
total = booksPage.total;
|
? searchBooks(searchQuery, libraryId, limit).catch(() => null)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const books = booksPage.items;
|
||||||
|
const total = booksPage.total;
|
||||||
|
if (searchResponse) {
|
||||||
|
seriesHits = searchResponse.series_hits ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayBooks = (searchResults || books).map(book => ({
|
const displayBooks = books.map(book => ({
|
||||||
...book,
|
...book,
|
||||||
coverUrl: getBookCoverUrl(book.id)
|
coverUrl: getBookCoverUrl(book.id)
|
||||||
}));
|
}));
|
||||||
@@ -142,11 +116,11 @@ export default async function BooksPage({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Résultats */}
|
{/* Résultats */}
|
||||||
{searchQuery && totalHits !== null ? (
|
{searchQuery ? (
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
{t("books.resultCountFor", { count: String(totalHits), plural: totalHits !== 1 ? "s" : "", query: searchQuery })}
|
{t("books.resultCountFor", { count: String(total), plural: total !== 1 ? "s" : "", query: searchQuery })}
|
||||||
</p>
|
</p>
|
||||||
) : !searchQuery && (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
{t("books.resultCount", { count: String(total), plural: total !== 1 ? "s" : "" })}
|
{t("books.resultCount", { count: String(total), plural: total !== 1 ? "s" : "" })}
|
||||||
</p>
|
</p>
|
||||||
@@ -194,14 +168,12 @@ export default async function BooksPage({
|
|||||||
{searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">{t("books.title")}</h2>}
|
{searchQuery && <h2 className="text-lg font-semibold text-foreground mb-3">{t("books.title")}</h2>}
|
||||||
<BooksGrid books={displayBooks} />
|
<BooksGrid books={displayBooks} />
|
||||||
|
|
||||||
{!searchQuery && (
|
<OffsetPagination
|
||||||
<OffsetPagination
|
currentPage={page}
|
||||||
currentPage={page}
|
totalPages={totalPages}
|
||||||
totalPages={totalPages}
|
pageSize={limit}
|
||||||
pageSize={limit}
|
totalItems={total}
|
||||||
totalItems={total}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message={searchQuery ? t("books.noResults", { query: searchQuery }) : t("books.noBooks")} />
|
<EmptyState message={searchQuery ? t("books.noResults", { query: searchQuery }) : t("books.noBooks")} />
|
||||||
|
|||||||
@@ -118,8 +118,13 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
|||||||
const textFields = fields.filter((f) => f.type === "text");
|
const textFields = fields.filter((f) => f.type === "text");
|
||||||
const selectFields = fields.filter((f) => f.type === "select");
|
const selectFields = fields.filter((f) => f.type === "select");
|
||||||
|
|
||||||
|
// Force remount when URL params change externally (back/forward, cookie redirect)
|
||||||
|
// so that defaultValue stays in sync with the URL.
|
||||||
|
const formKey = searchParams.toString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
|
key={formKey}
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -430,8 +430,10 @@ export async function fetchBooks(
|
|||||||
author?: string,
|
author?: string,
|
||||||
format?: string,
|
format?: string,
|
||||||
metadataProvider?: string,
|
metadataProvider?: string,
|
||||||
|
q?: string,
|
||||||
): Promise<BooksPageDto> {
|
): Promise<BooksPageDto> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
if (q) params.set("q", q);
|
||||||
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user