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
This commit is contained in:
2026-03-02 15:45:52 -05:00
parent 6454ade2f7
commit 240b3247aa
+608
View File
@@ -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
<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):
```go
<!-- 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):
```go
<button onclick="showAddBooksModal()" class="btn-primary px-4 py-2 rounded-lg">
Add Books
</button>
```
**Add** the toggle after this button:
```go
<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):
```go
<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**:
```go
<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:
```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<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):
```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.