# Implementation Plan: Collection Detail Page Fix + library_id Filter ## Overview Fix the broken `/collections/:id` page (inline JS bug) and add library_id support to the search API. This uses a hybrid approach: minimal TypeScript for client-only features, HTMX-like patterns for CRUD operations. --- ## Files to Modify | File | Changes | |------|---------| | `templates/collections.templ` | Remove inline JS, add data attributes, add toggle UI | | `web/src/collections.ts` | Add minimal TypeScript functions (~60 lines) | | `internal/handlers/media.go` | Add library_id param to search handler | | `internal/router/frontend.go` | (No changes needed - existing modals work) | --- ## Step 1: Update Search API to Accept library_id ### File: `internal/handlers/media.go` **Find** (around line 1418-1442): ```go // SearchMediaItems handles GET /api/media-items/search func (mh *MediaHandler) SearchMediaItems(c echo.Context) error { query := c.QueryParam("q") userID := c.Get("user_id").(string) if query == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "query parameter 'q' is required"}) } userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) } limit := int32(50) offset := int32(0) searchPattern := "%" + query + "%" partialResults, err := mh.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{ SearchPattern: pgtype.Text{String: searchPattern, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Limit: pgtype.Int4{Int32: limit, Valid: true}, Offset: pgtype.Int4{Int32: offset, Valid: true}, }) ``` **Replace with**: ```go // SearchMediaItems handles GET /api/media-items/search func (mh *MediaHandler) SearchMediaItems(c echo.Context) error { query := c.QueryParam("q") userID := c.Get("user_id").(string) libraryID := c.QueryParam("library_id") // Optional: filter by library if query == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "query parameter 'q' is required"}) } userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) } // Validate library_id if provided var libUUID *uuid.UUID if libraryID != "" { lib, err := uuid.Parse(libraryID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) } libUUID = &lib } limit := int32(50) offset := int32(0) searchPattern := "%" + query + "%" // Build params - conditionally add library_id filter params := database.SearchMediaItemsParams{ SearchPattern: pgtype.Text{String: searchPattern, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Limit: pgtype.Int4{Int32: limit, Valid: true}, Offset: pgtype.Int4{Int32: offset, Valid: true}, } // If library_id provided, add to query params if libUUID != nil { params.LibraryID = pgtype.UUID{Bytes: *libUUID, Valid: true} } partialResults, err := mh.db.SearchMediaItems(c.Request().Context(), params) ``` **Note**: You need to check if `SearchMediaItemsParams` in `database.SearchMediaItemsParams` already has a `LibraryID` field. If not, you'll need to add it to the SQL query. --- ## Step 2: Add library_id to SQL Query (if needed) ### File: `internal/database/queries.sql` **Find** the `SearchMediaItems` query: ```sql -- name: SearchMediaItems :many SELECT ... FROM media_items mi WHERE ... ``` **Add** library_id filter (if not present): ```sql -- name: SearchMediaItems :many SELECT mi.id, mi.user_id, mi.title, mi.author, mi.cover_image_path, mi.library_id, ... FROM media_items mi WHERE ... AND ($1::uuid IS NULL OR mi.library_id = $1::uuid) -- Add this filter ``` **Update** the `SearchMediaItemsParams` struct in `queries.sql.go` if needed to include LibraryID. --- ## Step 3: Remove Inline JS from Template ### File: `templates/collections.templ` **Find** the inline script block (lines 255-520): ```go ``` **Replace with** a hidden data element (add after the `` tag or where appropriate): ```go ``` **Note**: You need to pass `libraryID` to the template. Check the handler in `frontend.go` that renders `CollectionDetail` and add `libraryID` to the template data. --- ## Step 4: Add Toggle UI to Add Books Modal ### File: `templates/collections.templ` **Find** the Add Books modal (around line 157): ```go ``` **Add** the toggle after this button: ```go {# Toggle only shows when library_id is present - handled by JS #} ``` **Find** the Add Books modal content (around line 339 in generated, find in source): ```go
...

Search and select books to add to this collection.

``` **Replace with**: ```go
...

Search and select books to add to this collection.

``` --- ## Step 5: Add Minimal TypeScript Functions ### File: `web/src/collections.ts` **Add** at the end of the file: ```typescript // ============================================================================ // Collection Detail Page - Minimal TypeScript (~60 lines) // ============================================================================ interface BookItem { id: string; title: string; author: string; cover: string; } let collectionId = ""; let libraryId = ""; let selectedBooks = new Set(); let booksToRemove = new Set(); // Initialize from data attributes (called on page load) function initCollectionDetail(): void { const dataEl = document.getElementById("collection-data"); if (dataEl) { collectionId = dataEl.dataset.id || ""; libraryId = dataEl.dataset.libraryId || ""; // Show/hide library filter toggle based on whether library_id is present const filterContainer = document.getElementById("library-filter-container"); if (filterContainer) { if (libraryId) { filterContainer.classList.remove("hidden"); // Default: checked (filter by library) const checkbox = document.getElementById("filter-by-library") as HTMLInputElement; if (checkbox) checkbox.checked = true; } else { filterContainer.classList.add("hidden"); } } } } // Get current library filter setting function getLibraryFilterParam(): string { if (!libraryId) return ""; const checkbox = document.getElementById("filter-by-library") as HTMLInputElement; if (checkbox && checkbox.checked) { return `&library_id=${libraryId}`; } return ""; } // Modal functions (called from onclick attributes) function showAddBooksModal(): void { const modal = document.getElementById("add-books-modal"); if (modal) modal.classList.remove("hidden"); selectedBooks.clear(); const results = document.getElementById("book-results"); if (results) { results.innerHTML = '

Enter at least 2 characters to search.

'; } } function hideAddBooksModal(): void { const modal = document.getElementById("add-books-modal"); if (modal) modal.classList.add("hidden"); const searchInput = document.getElementById("book-search") as HTMLInputElement; if (searchInput) searchInput.value = ""; const results = document.getElementById("book-results"); if (results) results.innerHTML = ""; selectedBooks.clear(); } // Search books - now includes library filter async function searchBooks(): Promise { const searchInput = document.getElementById("book-search") as HTMLInputElement; const container = document.getElementById("book-results"); if (!searchInput || !container) return; const searchTerm = searchInput.value; if (searchTerm.length < 2) { container.innerHTML = '

Enter at least 2 characters to search.

'; return; } container.innerHTML = '

Searching...

'; const libraryFilter = getLibraryFilterParam(); const token = localStorage.getItem("token"); try { const response = await fetch(`/api/media-items/search?q=${encodeURIComponent(searchTerm)}${libraryFilter}`, { headers: { Authorization: `Bearer ${token}` }, }); const result = await response.json(); if (result.length > 0) { let html = '
'; result.slice(0, 50).forEach((book: any) => { const isSelected = selectedBooks.has(book.media_item_id); const checkedAttr = isSelected ? "checked" : ""; const authorHtml = book.author ? `
${book.author}
` : ""; const libraryBadge = book.library_id === libraryId ? 'This Library' : ''; html += `
Cover
${book.title}
${authorHtml}
${libraryBadge}
`; }); html += "
"; container.innerHTML = html; } else { container.innerHTML = '

No books found

'; } } catch (error) { container.innerHTML = '

Failed to search books

'; } } // Toggle book selection function toggleBookSelection(bookId: string): void { if (selectedBooks.has(bookId)) { selectedBooks.delete(bookId); } else { selectedBooks.add(bookId); } searchBooks(); } // Add selected books to collection async function addSelectedBooks(): Promise { if (selectedBooks.size === 0) { (window as any).showToast?.("Please select at least one book", "error"); return; } const bookIds = Array.from(selectedBooks); const token = localStorage.getItem("token"); try { const response = await fetch(`/api/collections/${collectionId}/books`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ book_ids: bookIds }), }); if (response.ok) { (window as any).showToast?.(`Added ${bookIds.length} book(s) to collection`, "success"); hideAddBooksModal(); location.reload(); } else { (window as any).showToast?.("Failed to add books", "error"); } } catch (error) { (window as any).showToast?.("Failed to add books", "error"); } } // Remove single book from collection async function removeBook(bookId: string): Promise { if (!confirm("Remove this book from the collection?")) return; const token = localStorage.getItem("token"); try { const response = await fetch(`/api/collections/${collectionId}/books/${bookId}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }); if (response.ok) { (window as any).showToast?.("Book removed from collection", "success"); location.reload(); } else { (window as any).showToast?.("Failed to remove book", "error"); } } catch (error) { (window as any).showToast?.("Failed to remove book", "error"); } } // Bulk remove functions function toggleBookForRemoval(bookId: string): void { if (booksToRemove.has(bookId)) { booksToRemove.delete(bookId); } else { booksToRemove.add(bookId); } updateSelectedCount(); } function updateSelectedCount(): void { const count = booksToRemove.size; const countSpan = document.getElementById("selected-count"); const removeBtn = document.getElementById("bulk-remove-btn") as HTMLButtonElement; if (count > 0) { if (countSpan) { countSpan.textContent = `${count} selected`; countSpan.classList.remove("hidden"); } if (removeBtn) removeBtn.disabled = false; } else { if (countSpan) countSpan.classList.add("hidden"); if (removeBtn) removeBtn.disabled = true; } } async function removeSelectedBooks(): Promise { if (booksToRemove.size === 0) { (window as any).showToast?.("No books selected", "error"); return; } if (!confirm(`Remove ${booksToRemove.size} book(s) from the collection?`)) return; const bookIds = Array.from(booksToRemove); const token = localStorage.getItem("token"); try { const response = await fetch(`/api/collections/${collectionId}/books/bulk-remove`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ book_ids: bookIds }), }); const result = await response.json(); if (result.removed > 0) { (window as any).showToast?.(`Removed ${result.removed} book(s) from collection`, "success"); location.reload(); } else { (window as any).showToast?.("Failed to remove books", "error"); } } catch (error) { (window as any).showToast?.("Failed to remove books", "error"); } } // Client-side search filter for displayed books function filterCollectionBooks(): void { const searchTerm = (document.getElementById("collection-search") as HTMLInputElement)?.value.toLowerCase() || ""; const booksContainer = document.getElementById("books-container"); if (!booksContainer) return; const bookCards = booksContainer.children; for (let i = 0; i < bookCards.length; i++) { const card = bookCards[i] as HTMLElement; if (card.id === "empty-state") continue; const titleEl = card.querySelector(".font-semibold"); const authorEl = card.querySelector(".text-sm"); const title = titleEl?.textContent?.toLowerCase() || ""; const author = authorEl?.textContent?.toLowerCase() || ""; const matches = title.includes(searchTerm) || author.includes(searchTerm); card.style.display = matches || searchTerm === "" ? "" : "none"; } } // Export functions globally (window as any).initCollectionDetail = initCollectionDetail; (window as any).showAddBooksModal = showAddBooksModal; (window as any).hideAddBooksModal = hideAddBooksModal; (window as any).searchBooks = searchBooks; (window as any).toggleBookSelection = toggleBookSelection; (window as any).addSelectedBooks = addSelectedBooks; (window as any).removeBook = removeBook; (window as any).toggleBookForRemoval = toggleBookForRemoval; (window as any).updateSelectedCount = updateSelectedCount; (window as any).removeSelectedBooks = removeSelectedBooks; (window as any).filterCollectionBooks = filterCollectionBooks; // Auto-initialize if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initCollectionDetail); } else { initCollectionDetail(); } ``` --- ## Step 6: Update Handler to Pass libraryID to Template ### File: `internal/router/frontend.go` **Find** the collection detail handler (around line 420): ```go // Render the CollectionDetail template var buf bytes.Buffer err = templates.CollectionDetail(user, colData, books).Render(c.Request().Context(), &buf) ``` **Replace with**: ```go // Get library_id from query params for template libraryID := c.QueryParam("library_id") // Render the CollectionDetail template var buf bytes.Buffer err = templates.CollectionDetail(user, colData, books, libraryID).Render(c.Request().Context(), &buf) ``` **Note**: You may need to update the `CollectionDetail` function signature in `templates/collections.templ` to accept `libraryID` parameter. --- ## Step 7: Update Template to Accept libraryID ### File: `templates/collections.templ` **Find** the CollectionDetail function signature: ```go func CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo) templ.Component { ``` **Replace with**: ```go func CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo, libraryID string) templ.Component { ``` --- ## Summary of Changes | Step | File | Lines Changed | Purpose | |------|------|---------------|---------| | 1 | `internal/handlers/media.go` | ~15 | Add library_id param to search | | 2 | `internal/database/queries.sql` | ~2 | Add library_id filter to SQL | | 3 | `templates/collections.templ` | -265 + 20 | Remove inline JS, add data attributes | | 4 | `templates/collections.templ` | ~15 | Add toggle UI to modal | | 5 | `web/src/collections.ts` | +~180 | Add minimal TypeScript | | 6 | `internal/router/frontend.go` | ~5 | Pass libraryID to template | | 7 | `templates/collections.templ` | ~3 | Accept libraryID in function signature | --- ## Testing Checklist - [ ] Navigate to `/collections/:id` - page loads without JS errors - [ ] Navigate to `/collections/:id?library_id=xxx` - page loads with toggle visible - [ ] Click "Add Books" - modal opens - [ ] Search in modal - results appear - [ ] With library_id + toggle checked - only books from that library shown - [ ] With library_id + toggle unchecked - all books shown - [ ] Add book to collection - success toast + page reloads - [ ] Remove book from collection - success toast + page reloads - [ ] Search within collection - books filter client-side --- ## Notes - The WebSocket real-time sync was removed from the TypeScript for simplicity. Can be added back if needed. - The toggle default is "checked" (filter by library) when library_id is present, matching the dashboard context. - The existing API handlers (`AddBooks`, `RemoveBook`, `BulkRemoveBooks`) don't need changes - they work with or without library_id.