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.