From ef4de7896aa6e8bdda3ba478d07d7457a3624375 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 2 Nov 2026 10:14:35 -0500 Subject: [PATCH] =?UTF-8?q?fix(csb):=20custom-section=20book=20picker=20se?= =?UTF-8?q?arch=20never=20worked=20=E2=80=94=20two=20latent=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fine-toothed functional verification of the delegation work exposed that the picker's book search has been dead since launch, for two independent reasons: - The builder fetched GET /api/books/search, a route no router has ever registered — every search 404'd (the CSP-dead onclick buttons on the results hid the failure). It now calls the unified JSON search the bookshelf uses (/api/media-items/search?q=&library_id=), maps its rows (id/title/author/cover_image_path) to the picker's BookInfo shape, and treats the endpoint's 404-with-results:[] as empty. - Even with results, the picker container shared id=search-results with the header search dropdown (header.templ renders it on every page); getElementById returns the first match in tree order, so results were written into the invisible topbar dropdown. The picker container is now cs-search-results (templ + all three TS references). Verified end-to-end at 390x844: select library -> search '1984' -> 3 results render -> click adds the selection chip -> chip x removes it. Zero console errors. --- templates/custom_section.templ | 2 +- templates/custom_section_templ.go | 2 +- web/src/custom-section-builder.ts | 42 ++++++++++++++++++++++++++----- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/templates/custom_section.templ b/templates/custom_section.templ index 45d3823f..6ef25626 100644 --- a/templates/custom_section.templ +++ b/templates/custom_section.templ @@ -136,7 +136,7 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri diff --git a/templates/custom_section_templ.go b/templates/custom_section_templ.go index 393c8916..140f4217 100644 --- a/templates/custom_section_templ.go +++ b/templates/custom_section_templ.go @@ -143,7 +143,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Search

No books selected

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Search

No books selected

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/custom-section-builder.ts b/web/src/custom-section-builder.ts index 8b058e21..a96941f0 100644 --- a/web/src/custom-section-builder.ts +++ b/web/src/custom-section-builder.ts @@ -261,7 +261,7 @@ function initCustomSectionBuilder(): void { // Delegated clicks for the generated picker/selection markup: the CSP // build refuses inline onclick= handlers, so the rows carry data // attributes and one listener per container survives innerHTML swaps. - const resultsContainer = document.getElementById("search-results"); + const resultsContainer = document.getElementById("cs-search-results"); resultsContainer?.addEventListener("click", (e) => { const row = (e.target as HTMLElement).closest("[data-book-id]"); if (!row) return; @@ -384,7 +384,11 @@ function onBookSearchInput(): void { async function searchBooks(): Promise { const searchInput = document.getElementById("book-search") as HTMLInputElement; const librarySelect = document.getElementById("section-library") as HTMLSelectElement; - const resultsContainer = document.getElementById("search-results") as HTMLElement; + // NOTE: cs-search-results, NOT search-results — the header search + // dropdown owns the latter on every page, and getElementById returns + // the first match in tree order (the picker's results were landing in + // the topbar dropdown, which stayed invisible). + const resultsContainer = document.getElementById("cs-search-results") as HTMLElement; const query = searchInput?.value.trim(); const libraryId = librarySelect?.value; @@ -395,22 +399,48 @@ async function searchBooks(): Promise { } try { + // The unified JSON search (same endpoint the bookshelf uses). The + // builder was wired to /api/books/search, a route that has never + // existed — every picker search 404'd. Rows come back with id/title/ + // author/cover_image_path; map to the BookInfo shape the picker + // renders (conflict/badge metadata is attached server-side later). const response = await fetch( - `/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`, + `/api/media-items/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`, { headers: { ...authHeaders(), - "Content-Type": "application/json", }, }, ); + if (response.status === 404) { + // Unified search signals "no results" as a 404 whose body still + // carries results: []. + displaySearchResults([]); + return; + } + if (!response.ok) { throw new Error("Failed to search books"); } const data = await response.json(); - displaySearchResults(data.books || []); + const rows: Array<{ + id?: string; + title?: string; + author?: string | null; + cover_image_path?: string | null; + }> = Array.isArray(data) ? data : data.results || []; + const books: BookInfo[] = rows.map((r) => ({ + media_item_id: r.id ?? "", + title: r.title ?? "", + author: r.author ?? "", + cover_image_path: r.cover_image_path ?? "", + has_conflict: false, + format_group: "", + badge: "", + })); + displaySearchResults(books); } catch (error) { console.error("Search books error:", error); showToast("Failed to search books", "error"); @@ -418,7 +448,7 @@ async function searchBooks(): Promise { } function displaySearchResults(books: BookInfo[]): void { - const resultsContainer = document.getElementById("search-results") as HTMLElement; + const resultsContainer = document.getElementById("cs-search-results") as HTMLElement; if (!resultsContainer) return; if (books.length === 0) {