Files
bookhoard/IMPLEMENTATION_COLLECTION_FIX.md
T
john-okeefe 240b3247aa 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
2026-03-02 15:45:52 -05:00

20 KiB
Raw Blame History

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):

// 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:

// 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:

-- name: SearchMediaItems :many
SELECT ...
FROM media_items mi
WHERE ...

Add library_id filter (if not present):

-- 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):

			<script>
            let collectionId = '{ collection.ID }';
            let selectedBooks = new Set();
            let booksToRemove = new Set();
            let allBooks = [
                { range books }{
                    {
                        id: '{ book.MediaItemID }',
                        title: '{ book.Title }',
                        author: '{ book.Author }',
                        cover: '{ book.CoverImagePath }'
                    },
                }{ end }
            ];
            let ws = null;

            function backToCollections() {
                window.location.href = '/collections';
            }

            // ... 265 lines of JavaScript ...
        </script>

Replace with a hidden data element (add after the <body> tag or where appropriate):

		<!-- Collection data for JavaScript -->
		<div id="collection-data"
		     data-id="{ collection.ID }"
		     data-library-id="{ libraryID }"
		     style="display: none;">
		</div>

		<!-- Include compiled TypeScript -->
		<script src="/static/collections.js"></script>

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):

						<button onclick="showAddBooksModal()" class="btn-primary px-4 py-2 rounded-lg">
							 Add Books
						</button>

Add the toggle after this button:

						<button onclick="showAddBooksModal()" class="btn-primary px-4 py-2 rounded-lg">
							 Add Books
						</button>
						{# 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):

<div id="add-books-modal" ...>
	...
	<p class="mb-4" style="color: var(--text-secondary)">Search and select books to add to this collection.</p>
	<div class="mb-4">
		<input type="text" id="book-search" placeholder="Search books..." ...>
	</div>
	<div id="book-results" ...>

Replace with:

<div id="add-books-modal" ...>
	...
	<p class="mb-4" style="color: var(--text-secondary)">Search and select books to add to this collection.</p>
	
	<!-- Library filter toggle - hidden by default, shown by JS when library_id present -->
	<div id="library-filter-container" class="mb-4 hidden">
		<label class="flex items-center gap-2 text-sm" style="color: var(--text-secondary);">
			<input type="checkbox" id="filter-by-library" class="w-4 h-4">
			<span>Only show books from this library</span>
		</label>
	</div>
	
	<div class="mb-4">
		<input type="text" id="book-search" placeholder="Search books..." ...>
	</div>
	<div id="book-results" ...>

Step 5: Add Minimal TypeScript Functions

File: web/src/collections.ts

Add at the end of the file:

// ============================================================================
// 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<string>();
let booksToRemove = new Set<string>();

// 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 = '<p class="text-sm" style="color: var(--text-secondary)">Enter at least 2 characters to search.</p>';
  }
}

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<void> {
  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 = '<p class="text-sm" style="color: var(--text-secondary)">Enter at least 2 characters to search.</p>';
    return;
  }

  container.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';

  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 = '<div class="space-y-2">';
      result.slice(0, 50).forEach((book: any) => {
        const isSelected = selectedBooks.has(book.media_item_id);
        const checkedAttr = isSelected ? "checked" : "";
        const authorHtml = book.author ? `<div class="text-xs" style="color: var(--text-secondary)">${book.author}</div>` : "";
        const libraryBadge = book.library_id === libraryId ? '<span class="text-xs px-1 bg-blue-500 text-white rounded">This Library</span>' : '';
        
        html += `
          <div class="flex items-center gap-3 p-2 rounded cursor-pointer hover:opacity-80"
               style="background-color: var(--bg-primary);"
               onclick="toggleBookSelection('${book.media_item_id}')">
            <input type="checkbox" ${checkedAttr} class="w-4 h-4">
            <img src="${book.cover_image_path || "/static/placeholder-book.svg"}"
                 alt="Cover" class="w-10 h-14 object-cover rounded">
            <div class="flex-1">
              <div class="text-sm font-medium" style="color: var(--text-primary)">${book.title}</div>
              ${authorHtml}
            </div>
            ${libraryBadge}
          </div>
        `;
      });
      html += "</div>";
      container.innerHTML = html;
    } else {
      container.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">No books found</p>';
    }
  } catch (error) {
    container.innerHTML = '<p class="text-sm" style="color: var(--error)">Failed to search books</p>';
  }
}

// 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<void> {
  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<void> {
  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<void> {
  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):

// Render the CollectionDetail template
var buf bytes.Buffer
err = templates.CollectionDetail(user, colData, books).Render(c.Request().Context(), &buf)

Replace with:

// 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:

func CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo) templ.Component {

Replace with:

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.