# 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 = '