From 240b3247aab78a5f5f5e83c605d51d327b5d8241 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 2 Mar 2026 15:45:52 -0500 Subject: [PATCH] docs: Add implementation plan for collection detail page fix and library filtering This document outlines the plan to fix the broken /collections/:id page which has an inline JavaScript bug, and add library_id support to the search API. Key changes planned: - Remove 265+ lines of inline JavaScript from collections.templ template - Add minimal TypeScript module (~180 lines) in web/src/collections.ts - Add optional library_id parameter to SearchMediaItems API endpoint - Add library filter toggle UI to the Add Books modal - Update template to accept libraryID parameter The implementation uses a hybrid approach: minimal TypeScript for client-only features while maintaining HTMX-like patterns for CRUD operations. This reduces maintenance burden and improves code organization. Steps detailed: 1. Update Search API to accept optional library_id parameter 2. Add library_id filter to SQL query if not present 3. Remove inline JS from template, add data attributes 4. Add toggle UI for filtering books by library 5. Add TypeScript functions for modal, search, and book management 6. Update handler to pass libraryID to template 7. Update template function signature Testing checklist included to verify: - Page loads without JS errors - Library filter toggle visibility - Search results with/without library filtering - Add/remove books functionality - Client-side search filtering --- IMPLEMENTATION_COLLECTION_FIX.md | 608 +++++++++++++++++++++++++++++++ 1 file changed, 608 insertions(+) create mode 100644 IMPLEMENTATION_COLLECTION_FIX.md diff --git a/IMPLEMENTATION_COLLECTION_FIX.md b/IMPLEMENTATION_COLLECTION_FIX.md new file mode 100644 index 0000000..04d72e5 --- /dev/null +++ b/IMPLEMENTATION_COLLECTION_FIX.md @@ -0,0 +1,608 @@ +# 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.