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

Books in this Collection

- +
@@ -147,13 +145,14 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo type="text" id="collection-search" placeholder="Search within collection..." - onkeyup="filterCollectionBooks()" + @input="filterCollectionBooks()" class="input" />
+
- +
+ +
+ } @@ -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, }));