From 03e4fce5f94b3496b92af0ebd527105010bf39d3 Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Sat, 28 Mar 2026 13:23:51 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20unifier=20la=20recherche=20livres=20via?= =?UTF-8?q?=20le=20endpoint=20/books=20avec=20param=C3=A8tre=20q?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/books.rs | 16 ++++- apps/backoffice/app/(app)/books/page.tsx | 72 ++++++------------- .../app/components/LiveSearchForm.tsx | 5 ++ apps/backoffice/lib/api.ts | 2 + 4 files changed, 44 insertions(+), 51 deletions(-) diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index d9d9a52..4b98fea 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -9,6 +9,9 @@ use crate::{auth::AuthUser, error::ApiError, index_jobs::IndexJobResponse, state #[derive(Deserialize, ToSchema)] pub struct ListBooksQuery { + /// Text search on title, series and author (case-insensitive, partial match) + #[schema(value_type = Option, example = "dragon")] + pub q: Option, #[schema(value_type = Option)] pub library_id: Option, #[schema(value_type = Option)] @@ -104,6 +107,7 @@ pub struct BookDetails { path = "/books", tag = "books", params( + ("q" = Option, Query, description = "Text search on title, series and author (case-insensitive, partial match)"), ("library_id" = Option, Query, description = "Filter by library ID"), ("kind" = Option, Query, description = "Filter by book kind (cbz, cbr, pdf, epub)"), ("series" = Option, 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}") }, 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; let uid_p = p; @@ -176,7 +183,8 @@ pub async fn list_books( {series_cond} {rs_cond} {author_cond} - {metadata_cond}"# + {metadata_cond} + {q_cond}"# ); let order_clause = if query.sort.as_deref() == Some("latest") { @@ -205,6 +213,7 @@ pub async fn list_books( {rs_cond} {author_cond} {metadata_cond} + {q_cond} ORDER BY {order_clause} LIMIT ${limit_p} OFFSET ${offset_p} "# @@ -239,6 +248,11 @@ pub async fn list_books( 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); data_builder = data_builder.bind(user_id).bind(limit).bind(offset); diff --git a/apps/backoffice/app/(app)/books/page.tsx b/apps/backoffice/app/(app)/books/page.tsx index b866ba9..fba2374 100644 --- a/apps/backoffice/app/(app)/books/page.tsx +++ b/apps/backoffice/app/(app)/books/page.tsx @@ -28,53 +28,27 @@ export default async function BooksPage({ fetchLibraries().catch(() => [] as LibraryDto[]) ]); - let books: BookDto[] = []; - let total = 0; - let searchResults: BookDto[] | null = null; let seriesHits: SeriesHitDto[] = []; - let totalHits: number | null = null; - if (searchQuery) { - const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null); - 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(() => ({ + const [booksPage, searchResponse] = await Promise.all([ + fetchBooks(libraryId, undefined, page, limit, readingStatus, sort, undefined, format, metadataProvider, searchQuery || undefined).catch(() => ({ items: [] as BookDto[], total: 0, page: 1, limit, - })); - books = booksPage.items; - total = booksPage.total; + })), + searchQuery + ? 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, coverUrl: getBookCoverUrl(book.id) })); @@ -142,11 +116,11 @@ export default async function BooksPage({ {/* Résultats */} - {searchQuery && totalHits !== null ? ( + {searchQuery ? (

- {t("books.resultCountFor", { count: String(totalHits), plural: totalHits !== 1 ? "s" : "", query: searchQuery })} + {t("books.resultCountFor", { count: String(total), plural: total !== 1 ? "s" : "", query: searchQuery })}

- ) : !searchQuery && ( + ) : (

{t("books.resultCount", { count: String(total), plural: total !== 1 ? "s" : "" })}

@@ -194,14 +168,12 @@ export default async function BooksPage({ {searchQuery &&

{t("books.title")}

} - {!searchQuery && ( - - )} + ) : ( diff --git a/apps/backoffice/app/components/LiveSearchForm.tsx b/apps/backoffice/app/components/LiveSearchForm.tsx index 4d6bd58..609a51e 100644 --- a/apps/backoffice/app/components/LiveSearchForm.tsx +++ b/apps/backoffice/app/components/LiveSearchForm.tsx @@ -118,8 +118,13 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc const textFields = fields.filter((f) => f.type === "text"); 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 (
{ e.preventDefault(); diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index 20db444..f47a109 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -430,8 +430,10 @@ export async function fetchBooks( author?: string, format?: string, metadataProvider?: string, + q?: string, ): Promise { const params = new URLSearchParams(); + if (q) params.set("q", q); if (libraryId) params.set("library_id", libraryId); if (series) params.set("series", series); if (readingStatus) params.set("reading_status", readingStatus);