From 816ee0ec807ffc74f90bc6ed8b07507b4bb34010 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 6 Aug 2026 10:40:18 -0400 Subject: [PATCH] fix(ui): wire up collection detail page interactions The /collections/:id page had several broken features because three referenced functions (removeBook, toggleBookForRemoval, filterCollectionBooks) were never defined, and every book card was wrapped in so clicking the checkbox or remove button navigated to the book detail page instead. Card restructure: - Remove the wrapper; title and cover are now individual links. - Checkbox sits in a
that nobody referenced). - Add show_checkbox mode to handleSearchHTML: when present, render a new BookPickerGrid template with clickable, selectable cards instead of the reader BookCard. - Fix bookPicker submit() to location.reload() instead of a non-existent reloadCollection HTMX event, and clearFilters() to target text inputs. --- internal/router/search.go | 8 +- templates/collections.templ | 121 +++++++++------ templates/collections_templ.go | 269 ++++++++++++++++++++++++++++----- web/src/bookPicker.ts | 17 +-- web/src/collections.ts | 146 +++++++++++++++--- 5 files changed, 445 insertions(+), 116 deletions(-) diff --git a/internal/router/search.go b/internal/router/search.go index 177b6f8..d3cd95e 100644 --- a/internal/router/search.go +++ b/internal/router/search.go @@ -114,9 +114,13 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error { } // Stamp active conflict flags so cards route the play action correctly bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList) - // Render using BooksGrid template + // Render using BooksGrid template (or BookPickerGrid for collection picker) var buf bytes.Buffer - err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf) + if c.QueryParam("show_checkbox") == "true" { + err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf) + } else { + err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf) + } if err != nil { log.Printf("Template render error: %v", err) return c.HTML(http.StatusInternalServerError, `
Render error
`) diff --git a/templates/collections.templ b/templates/collections.templ index 924f5d5..3cb50f7 100644 --- a/templates/collections.templ +++ b/templates/collections.templ @@ -137,9 +137,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
- +
+ +
+ } @@ -267,14 +265,14 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo class="p-4 border-b" style="border-color: var(--border);" > -
+
Clear -
- - +
} + +templ BookPickerGrid(books []handlers.BookInfo) { + for _, book := range books { +
+
+ if book.CoverImagePath != "" { + { + } else { + { + } +
+
+ ✓ +
+
+

{ book.Title }

+
+ } +} diff --git a/templates/collections_templ.go b/templates/collections_templ.go index 89027cf..3f3fa00 100644 --- a/templates/collections_templ.go +++ b/templates/collections_templ.go @@ -311,7 +311,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

Books in this Collection

0 selected

Books in this Collection

0\" x-text=\"selectedBooks.length + ' selected'\" class=\"badge\" style=\"display: none; background-color: var(--accent); color: var(--bg-primary);\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "Remove from Collection") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "

Add Books to Collection

Add Books to Collection

books selected
Clear
books selected
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" style=\"display: none;\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -478,4 +530,141 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo }) } +func BookPickerGrid(books []handlers.BookInfo) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var24 := templ.GetChildren(ctx) + if templ_7745c5c3_Var24 == nil { + templ_7745c5c3_Var24 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + for _, book := range books { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.CoverImagePath != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\"")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\"")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 383, Col: 87} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + var _ = templruntime.GeneratedTemplate diff --git a/web/src/bookPicker.ts b/web/src/bookPicker.ts index 2befb2f..8fcd19a 100644 --- a/web/src/bookPicker.ts +++ b/web/src/bookPicker.ts @@ -48,18 +48,16 @@ Alpine.store("bookPicker", { }, clearFilters() { - const filterForm = document.getElementById( - "book-picker-filters", - ) as HTMLFormElement; - if (!filterForm) return; + const filterDiv = document.getElementById("book-picker-filters"); + if (!filterDiv) return; - // Reset all form fields except checkbox state (that's in Alpine.store) - const inputs = filterForm.querySelectorAll("input:not([type='checkbox'])"); + const inputs = filterDiv.querySelectorAll( + 'input[type="text"]', + ) as NodeListOf; inputs.forEach((input) => { - (input as HTMLInputElement).value = ""; + input.value = ""; }); - // Reload books - selection state preserved in Alpine.store this.loadBooks(); }, @@ -97,8 +95,7 @@ Alpine.store("bookPicker", { "success", ); this.close(); - // Reload collection detail page - window.htmx.trigger(document.body, "reloadCollection"); + location.reload(); } else { showToast("Failed to add books", "error"); } diff --git a/web/src/collections.ts b/web/src/collections.ts index 2dbdaf0..3e0275f 100644 --- a/web/src/collections.ts +++ b/web/src/collections.ts @@ -135,29 +135,36 @@ function renderCollectionBooks(books: BookInfo[]): void { container.innerHTML = books .map( (book) => ` - -
-
-
- -
-
-

${book.title}

- ${book.author ? `

by ${book.author}

` : ""} -
-
+ - +
+ +
+
`, ) .join(""); @@ -393,6 +400,9 @@ function setupHTMXModalInit(): void { populateIconGrid(); Alpine.initTree(target); } + if (target && target.id === "book-picker-grid") { + Alpine.initTree(target); + } }); } @@ -553,10 +563,103 @@ function initCollectionsPage(): void { setupHTMXModalInit(); } +function getCollectionId(): string { + const dataEl = document.getElementById("collection-data"); + return dataEl?.dataset.id || ""; +} + +function toggleSelection(this: any, id: string): void { + const idx = this.selectedBooks.indexOf(id); + if (idx >= 0) { + this.selectedBooks.splice(idx, 1); + } else { + this.selectedBooks.push(id); + } +} + +async function removeBook(id: string): Promise { + if (!confirm("Remove this book from the collection?")) return; + const token = localStorage.getItem("token"); + if (!token) return; + try { + const response = await fetch( + `/api/collections/${getCollectionId()}/books/${id}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }, + ); + if (response.ok) { + showToast("Removed from collection", "success"); + setTimeout(() => location.reload(), 500); + } else { + showToast("Failed to remove book", "error"); + } + } catch { + showToast("Error removing book", "error"); + } +} + +async function bulkRemove(this: any): Promise { + if (this.selectedBooks.length === 0) return; + if ( + !confirm( + `Remove ${this.selectedBooks.length} book(s) from this collection?`, + ) + ) + return; + const token = localStorage.getItem("token"); + if (!token) return; + try { + const response = await fetch( + `/api/collections/${getCollectionId()}/books/bulk-remove`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ book_ids: this.selectedBooks }), + }, + ); + if (response.ok) { + showToast( + `Removed ${this.selectedBooks.length} book(s)`, + "success", + ); + this.selectedBooks = []; + setTimeout(() => location.reload(), 500); + } else { + showToast("Failed to remove books", "error"); + } + } catch { + showToast("Error removing books", "error"); + } +} + +function filterCollectionBooks(): void { + const input = document.getElementById( + "collection-search", + ) as HTMLInputElement; + if (!input) return; + const query = input.value.toLowerCase(); + const cards = document.querySelectorAll( + ".collection-book-card", + ); + cards.forEach((card) => { + const title = (card.dataset.title || "").toLowerCase(); + const author = (card.dataset.author || "").toLowerCase(); + card.style.display = + title.includes(query) || author.includes(query) ? "" : "none"; + }); +} + export { + bulkRemove, closeCollectionModal, createRule, deleteRule, + filterCollectionBooks, filterIcons, initColorSelection, initializeCollectionWebSocket, @@ -565,18 +668,21 @@ export { loadCollections, navigateToCollection, populateIconGrid, + removeBook, selectColor, selectIcon, setupHTMXAuth, setupHTMXModalInit, showAllIcons, testRule, + toggleSelection, }; Alpine.data("collections", () => ({ closeCollectionModal, createRule, deleteRule, + filterCollectionBooks, filterIcons, initColorSelection, initializeCollectionWebSocket, @@ -585,9 +691,13 @@ Alpine.data("collections", () => ({ loadCollections, navigateToCollection, populateIconGrid, + removeBook, selectColor, selectIcon, + selectedBooks: [] as string[], setupHTMXModalInit, showAllIcons, testRule, + toggleSelection, + bulkRemove, }));