fix(csb): custom-section book picker search never worked — two latent bugs
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.
This commit is contained in:
1 parent
12aab156f6
commit
ef4de7896a
3 files changed
+38
-8
No files matched your search
@@ -136,7 +136,7 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="search-results"
|
id="cs-search-results"
|
||||||
class="hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2"
|
class="hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2"
|
||||||
style="background-color: var(--bg-primary);"
|
style="background-color: var(--bg-primary);"
|
||||||
></div>
|
></div>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Search</button></div></div><div id=\"search-results\" class=\"hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2\" style=\"background-color: var(--bg-primary);\"></div><div class=\"mb-4\"><label class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-xl border-2 border-dashed\" style=\"border-color: var(--border-strong); background-color: var(--bg-primary);\"><p class=\"text-sm text-center\" style=\"color: var(--text-secondary);\">No books selected</p></div></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Search</button></div></div><div id=\"cs-search-results\" class=\"hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2\" style=\"background-color: var(--bg-primary);\"></div><div class=\"mb-4\"><label class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-xl border-2 border-dashed\" style=\"border-color: var(--border-strong); background-color: var(--bg-primary);\"><p class=\"text-sm text-center\" style=\"color: var(--text-secondary);\">No books selected</p></div></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ function initCustomSectionBuilder(): void {
|
|||||||
// Delegated clicks for the generated picker/selection markup: the CSP
|
// Delegated clicks for the generated picker/selection markup: the CSP
|
||||||
// build refuses inline onclick= handlers, so the rows carry data
|
// build refuses inline onclick= handlers, so the rows carry data
|
||||||
// attributes and one listener per container survives innerHTML swaps.
|
// 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) => {
|
resultsContainer?.addEventListener("click", (e) => {
|
||||||
const row = (e.target as HTMLElement).closest<HTMLElement>("[data-book-id]");
|
const row = (e.target as HTMLElement).closest<HTMLElement>("[data-book-id]");
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
@@ -384,7 +384,11 @@ function onBookSearchInput(): void {
|
|||||||
async function searchBooks(): Promise<void> {
|
async function searchBooks(): Promise<void> {
|
||||||
const searchInput = document.getElementById("book-search") as HTMLInputElement;
|
const searchInput = document.getElementById("book-search") as HTMLInputElement;
|
||||||
const librarySelect = document.getElementById("section-library") as HTMLSelectElement;
|
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 query = searchInput?.value.trim();
|
||||||
const libraryId = librarySelect?.value;
|
const libraryId = librarySelect?.value;
|
||||||
@@ -395,22 +399,48 @@ async function searchBooks(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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(
|
const response = await fetch(
|
||||||
`/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`,
|
`/api/media-items/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
...authHeaders(),
|
...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) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to search books");
|
throw new Error("Failed to search books");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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) {
|
} catch (error) {
|
||||||
console.error("Search books error:", error);
|
console.error("Search books error:", error);
|
||||||
showToast("Failed to search books", "error");
|
showToast("Failed to search books", "error");
|
||||||
@@ -418,7 +448,7 @@ async function searchBooks(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function displaySearchResults(books: BookInfo[]): void {
|
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 (!resultsContainer) return;
|
||||||
|
|
||||||
if (books.length === 0) {
|
if (books.length === 0) {
|
||||||
|
|||||||
Reference in new issue
Block a user