diff --git a/COLLECTION_LIBRARY_FILTERING_PLAN.md b/COLLECTION_LIBRARY_FILTERING_PLAN.md
deleted file mode 100644
index 28080e8..0000000
--- a/COLLECTION_LIBRARY_FILTERING_PLAN.md
+++ /dev/null
@@ -1,291 +0,0 @@
-# Collection Library Filtering Implementation Plan
-
-## Overview
-
-Implement library-aware filtering for collection detail pages to preserve context when navigating from the dashboard.
-
-**Current Behavior:**
-- Dashboard shows collections filtered by selected library
-- Clicking "View All" navigates to `/collections/:id` showing ALL books across all libraries
-- This causes UX confusion: user sees 2 books on dashboard, 5 on collection page
-
-**Target Behavior:**
-- Navigate to `/collections/:id` → Show ALL books in collection (maintains backward compatibility)
-- Navigate to `/collections/:id?library_id=xxx` → Show books from that specific library only
-- Dashboard "View All" links preserve library context
-
----
-
-## Files to Modify
-
-### 1. `internal/handlers/dashboard.go`
-
-**Location:** Lines 188-193
-
-**Current Code:**
-```go
-func getViewAllURL(collectionID string) string {
- if collectionID != "" {
- return "/collections/" + collectionID
- }
- return ""
-}
-```
-
-**Change To:**
-```go
-func getViewAllURL(collectionID string) libraryID string) string {
- if collectionID != "" {
- if libraryID != "" {
- return "/collections/" + collectionID + "?library_id=" + libraryID
- }
- return "/collections/" + collectionID
- }
- return ""
-}
-```
-
-**Also Update Line 180:**
-```go
-// Current:
-ViewAllURL: getViewAllURL(ds.CollectionID.String()),
-
-// Change To:
-ViewAllURL: getViewAllURL(ds.CollectionID.String(), currentLibraryID),
-```
-
-**Also Update Function Signature (Line 157):**
-```go
-// Current:
-func BuildSections(sections []services.DashboardSection) []SectionData {
-
-// Change To:
-func BuildSections(sections []services.DashboardSection, currentLibraryID string) []SectionData {
-```
-
-**Also Update Call Site (frontend.go Lines 194, 195):**
-```go
-// Current:
-sectionData := handlers.BuildSections(visibleSections)
-allSectionsData := handlers.BuildSections(allSections)
-
-// Change To:
-sectionData := handlers.BuildSections(visibleSections, libraryID)
-allSectionsData := handlers.BuildSections(allSections, libraryID)
-```
-
----
-
-### 2. `internal/router/frontend.go`
-
-**Location:** Lines 360-378 (collection detail page handler)
-
-**Current Code:**
-```go
-} else {
- // User collection - fetch collection items
- collItems, err := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
- if err != nil {
- books = []handlers.BookInfo{}
- }
-
- // Convert to BookInfo format
- bookCards := make([]handlers.BookInfo, len(collItems))
- for i, item := range collItems {
- itemUUID, _ := uuid.FromBytes(item.MediaItemID.Bytes[0:16])
- bookCards[i] = handlers.BookInfo{
- MediaItemID: itemUUID.String(),
- Title: item.Title,
- Author: getText(item.Author),
- CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
- }
- }
- books = bookCards
-}
-```
-
-**Change To:**
-```go
-} else {
- // User collection - check if library_id filter is present
- libraryID := c.QueryParam("library_id")
-
- if libraryID != "" {
- // Filter by library - reuse dashboard query
- libUUID, err := uuid.Parse(libraryID)
- if err != nil {
- return renderErrorPage(c, "Invalid library ID", "invalid_library_id")
- }
-
- // Use GetCollectionItemsForDashboard for library-filtered results
- collItems, err := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
- database.GetCollectionItemsForDashboardParams{
- CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
- LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
- Limit: 1000,
- })
- if err != nil {
- books = []handlers.BookInfo{}
- } else {
- // Convert to BookInfo format (non-excluded only)
- var validItems []database.GetCollectionItemsForDashboardRow
- for _, item := range collItems {
- if !item.Excluded.Valid || !item.Excluded.Bool {
- validItems = append(validItems, item)
- }
- }
-
- bookCards := make([]handlers.BookInfo, len(validItems))
- for i, item := range validItems {
- itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
- bookCards[i] = handlers.BookInfo{
- MediaItemID: itemUUID.String(),
- Title: item.Title,
- Author: getText(item.Author),
- CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
- }
- }
- books = bookCards
- }
- } else {
- // No library filter - show all books in collection
- collItems, err := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
- if err != nil {
- books = []handlers.BookInfo{}
- }
-
- // Convert to BookInfo format
- bookCards := make([]handlers.BookInfo, len(collItems))
- for i, item := range collItems {
- itemUUID, _ := uuid.FromBytes(item.MediaItemID.Bytes[0:16])
- bookCards[i] = handlers.BookInfo{
- MediaItemID: itemUUID.String(),
- Title: item.Title,
- Author: getText(item.Author),
- CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
- }
- }
- books = bookCards
- }
-}
-```
-
----
-
-### 3. `internal/handlers/dashboard_test.go`
-
-**Location:** Lines 60-66
-
-**Current Code:**
-```go
-assert.Equal(t, "/section/continue-reading", section1.ViewAllURL, "ViewAllURL should match system collection")
-assert.Equal(t, "", section2.ViewAllURL, "ViewAllURL should be empty for user collections")
-```
-
-**Add New Test Function:**
-```go
-func TestGetViewAllURLWithLibrary(t *testing.T) {
- tests := []struct {
- name string
- collectionID string
- libraryID string
- expected string
- }{
- {
- name: "with library ID",
- collectionID: "collection-123",
- libraryID: "library-abc",
- expected: "/collections/collection-123?library_id=library-abc",
- },
- {
- name: "without library ID",
- collectionID: "collection-123",
- libraryID: "",
- expected: "/collections/collection-123",
- },
- {
- name: "empty collection ID",
- collectionID: "",
- libraryID: "library-abc",
- expected: "",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := getViewAllURL(tt.collectionID, tt.libraryID)
- assert.Equal(t, tt.expected, result, "getViewAllURL mismatch")
- })
- }
-}
-```
-
----
-
-### 4. `docs/user/collections.md` (Create if doesn't exist)
-
-**Create documentation file:**
-```markdown
-# Collections
-
-Collections allow you to organize books across multiple libraries.
-
-## Viewing Collections
-
-### From Collections Page
-Navigate to `/collections` to see all your collections. Clicking on a collection shows ALL books in that collection across all libraries.
-
-### From Dashboard
-When viewing a specific library's dashboard, collections only show books from that library. Clicking "View All" preserves this context and shows only books from that library in the collection.
-
-### Library Filtering
-- **No filter** (`/collections/favorites`): Shows all books across all libraries
-- **With filter** (`/collections/favorites?library_id=xxx`): Shows only books from the specified library
-
-## Creating Collections
-
-[Instructions for creating collections]
-
-## Managing Collections
-
-[Instructions for editing/deleting collections]
-```
-
----
-
-## Implementation Order
-
-1. **Step 1:** Modify `dashboard.go` `getViewAllURL()` function signature and implementation
-2. **Step 2:** Update `BuildSections()` to accept and pass `currentLibraryID`
-3. **Step 3:** Update call sites in `frontend.go` dashboard handler
-4. **Step 4:** Modify `frontend.go` collection detail handler to support library_id query parameter
-5. **Step 5:** Add tests to `dashboard_test.go`
-6. **Step 6:** Create/update documentation in `docs/user/collections.md`
-7. **Step 7:** Rebuild TypeScript if needed (no changes expected)
-8. **Step 8:** Rebuild container: `podman compose build`
-9. **Step 9:** Restart container: `podman compose up -d`
-10. **Step 10:** Test navigation from dashboard to collection detail page
-
----
-
-## Testing Checklist
-
-- [ ] Navigate to dashboard with Library A selected
-- [ ] Click "View All" on a collection
-- [ ] Verify URL includes `?library_id=library-a-id`
-- [ ] Verify only books from Library A appear
-- [ ] Navigate to collections page directly
-- [ ] Click on same collection
-- [ ] Verify URL has no library_id parameter
-- [ ] Verify ALL books from all libraries appear
-- [ ] Run tests: `go test ./internal/handlers/... -v`
-- [ ] Verify no regressions in existing functionality
-
----
-
-## Notes
-
-- **Backward Compatibility:** URLs without `library_id` parameter work exactly as before
-- **Reuses Existing Query:** `GetCollectionItemsForDashboard` already filters by library - no new SQL needed
-- **Progressive Enhancement:** Works without JavaScript (server-side rendering)
-- **Follows Guidelines:** No custom CSS, TypeScript only, service layer pattern maintained
diff --git a/IMPLEMENTATION_COLLECTION_FIX.md b/IMPLEMENTATION_COLLECTION_FIX.md
deleted file mode 100644
index 4b57b7a..0000000
--- a/IMPLEMENTATION_COLLECTION_FIX.md
+++ /dev/null
@@ -1,1446 +0,0 @@
-# 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 is a **full-stack task** involving:
-- Backend: Search API enhancement, WebSocket permission fix
-- Database: SQL query modification
-- Frontend: TypeScript conversion, UI improvements
-
-Uses hybrid approach: minimal TypeScript for client-only features, HTMX-like patterns for CRUD operations, WebSocket for real-time sync.
-
----
-
-## Files to Modify
-
-| File | Changes |
-|------|---------|
-| `internal/sync/websocket.go` | Add user-scoped broadcast method |
-| `internal/handlers/collections.go` | Use user-scoped broadcasts |
-| `internal/database/queries.sql` | Add library_id filter to search |
-| `internal/handlers/media.go` | Add library_id param to search handler |
-| `templates/collections.templ` | Remove inline JS, add data attributes, add toggle UI |
-| `web/src/collections.ts` | Add TypeScript with WebSocket support |
-| `internal/router/frontend.go` | Pass libraryID to template |
-| `cmd/server/tests/collections_test.go` | Add integration tests |
-| `docs/developer/api/search.md` | Document library_id parameter |
-| `bruno/collections/search-with-library-filter.yml` | API test for new parameter |
-
----
-
-## Git Commit Strategy
-
-Use multiple logical commits:
-
-1. **feat(websocket): Add user-scoped broadcasting**
- - `internal/sync/websocket.go`
- - `internal/handlers/collections.go`
- - Tests for user-scoped broadcasts
-
-2. **feat(database): Add library_id filter to search queries**
- - `internal/database/queries.sql`
- - Regenerate `internal/database/queries.sql.go`
- - Verify params struct updated
-
-3. **feat(api): Add library_id parameter to search endpoint**
- - `internal/handlers/media.go`
- - Integration tests for library filtering
-
-4. **refactor(templates): Remove inline JS from collection detail**
- - `templates/collections.templ`
- - `internal/router/frontend.go`
- - `web/src/collections.ts`
-
-5. **feat(frontend): Add library filter toggle UI**
- - `templates/collections.templ`
- - `web/src/collections.ts`
-
-6. **docs(api): Document library_id search parameter**
- - `docs/developer/api/search.md`
- - `bruno/collections/search-with-library-filter.yml`
-
----
-
-## Step 1: Add User-Scoped Broadcasting (WebSocket Fix)
-
-### File: `internal/sync/websocket.go`
-
-**Add after line 94** (after existing `Broadcast` method):
-
-```go
-// BroadcastToUser sends a message to all connections for a specific user
-func (m *ConnectionManager) BroadcastToUser(userID string, msg BroadcastMessage) {
- m.mu.RLock()
- defer m.mu.RUnlock()
-
- for _, conn := range m.connections {
- if conn.UserID == userID {
- select {
- case conn.Send <- msg:
- default:
- // Channel full, skip this connection
- log.Printf("WebSocket: Channel full for %s, skipping broadcast", conn.DeviceName)
- }
- }
- }
-}
-```
-
-**Why**: Current `Broadcast()` sends to ALL users (security issue). User-scoped broadcasts ensure collection updates only go to that user's devices.
-
----
-
-## Step 2: Update Collections Handler to Use User-Scoped Broadcasts
-
-### File: `internal/handlers/collections.go`
-
-**Find all instances of** `h.connManager.Broadcast` **and replace with user-scoped**:
-
-**Line 333** (AddBooks):
-```go
-if addedCount > 0 && h.connManager != nil {
- h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
- Type: "collection_updated",
- Timestamp: time.Now().Format(time.RFC3339),
- Data: map[string]interface{}{
- "collection_id": collectionID.String(),
- "action": "books_added",
- "book_ids": addedBookIDs,
- "count": addedCount,
- },
- })
-}
-```
-
-**Line 401** (RemoveBook):
-```go
-if h.connManager != nil {
- h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
- Type: "collection_updated",
- Timestamp: time.Now().Format(time.RFC3339),
- Data: map[string]interface{}{
- "collection_id": collectionID.String(),
- "action": "book_removed",
- "book_id": bookID.String(),
- },
- })
-}
-```
-
-**Line 831** (BulkRemoveBooks):
-```go
-if removedCount > 0 && h.connManager != nil {
- h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
- Type: "collection_updated",
- Timestamp: time.Now().Format(time.RFC3339),
- Data: map[string]interface{}{
- "collection_id": collectionID.String(),
- "action": "books_bulk_removed",
- "book_ids": removedBookIDs,
- "count": removedCount,
- },
- })
-}
-```
-
-**Why**: Ensures collection updates only broadcast to the user who made the change, not all connected users.
-
----
-
-## Step 3: Add library_id Filter to SQL Query
-
-### File: `internal/database/queries.sql`
-
-**Find the `SearchMediaItems` query** (around line 393):
-
-**Current:**
-```sql
--- name: SearchMediaItems :many
-SELECT mi.*, l.name as library_name, lt.name as library_type_name
-FROM media_items mi
-JOIN libraries l ON mi.library_id = l.id
-JOIN library_types lt ON l.library_type_id = lt.id
-LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
-WHERE COALESCE(lv.is_visible, true) = true
- AND (
- mi.title ILIKE sqlc.narg('search_pattern') OR
- mi.author ILIKE sqlc.narg('search_pattern') OR
- mi.series ILIKE sqlc.narg('search_pattern') OR
- sqlc.narg('search_pattern') = ANY(mi.tags_search) OR
- sqlc.narg('search_pattern') = ANY(mi.contributors_search)
- )
-ORDER BY ...
-```
-
-**Add library_id parameter and filter**:
-
-```sql
--- name: SearchMediaItems :many
-SELECT mi.*, l.name as library_name, lt.name as library_type_name
-FROM media_items mi
-JOIN libraries l ON mi.library_id = l.id
-JOIN library_types lt ON l.library_type_id = lt.id
-LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
-WHERE COALESCE(lv.is_visible, true) = true
- AND ($5::uuid IS NULL OR mi.library_id = $5::uuid) -- library_id filter
- AND (
- mi.title ILIKE sqlc.narg('search_pattern') OR
- mi.author ILIKE sqlc.narg('search_pattern') OR
- mi.series ILIKE sqlc.narg('search_pattern') OR
- sqlc.narg('search_pattern') = ANY(mi.tags_search) OR
- sqlc.narg('search_pattern') = ANY(mi.contributors_search)
- )
-ORDER BY ...
-```
-
-**Also update `SearchMediaItemsFuzzy`** (around line 418) with the same filter:
-```sql
-WHERE COALESCE(lv.is_visible, true) = true
- AND ($5::uuid IS NULL OR mi.library_id = $5::uuid) -- library_id filter
- AND (
- word_similarity(sqlc.narg('search_query'), mi.title) > 0.3 OR
- ...
- )
-```
-
-**Regenerate database code:**
-```bash
-cd internal/database
-sqlc generate
-```
-
-**Verify** `internal/database/queries.sql.go` now has:
-```go
-type SearchMediaItemsParams struct {
- UserID pgtype.UUID `db:"user_id" json:"user_id"`
- SearchPattern pgtype.Text `db:"search_pattern" json:"search_pattern"`
- Offset pgtype.Int4 `db:"offset" json:"offset"`
- Limit pgtype.Int4 `db:"limit" json:"limit"`
- LibraryID pgtype.UUID `db:"library_id" json:"library_id"` // NEW
-}
-```
-
----
-
-## Step 4: Update Search API to Accept library_id
-
-### File: `internal/handlers/media.go`
-
-**Find** (around line 1418-1472):
-
-**Current:**
-```go
-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},
- })
-
- if err != nil && err != pgx.ErrNoRows {
- return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
- }
-
- if len(partialResults) > 0 {
- return c.JSON(http.StatusOK, partialResults)
- }
-
- fuzzyResults, err := mh.db.SearchMediaItemsFuzzy(c.Request().Context(), database.SearchMediaItemsFuzzyParams{
- SearchQuery: pgtype.Text{String: query, Valid: true},
- UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
- Limit: pgtype.Int4{Int32: limit, Valid: true},
- Offset: pgtype.Int4{Int32: offset, Valid: true},
- })
- // ... rest of function
-```
-
-**Replace with:**
-```go
-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 pgtype.UUID
- if libraryID != "" {
- lib, err := uuid.Parse(libraryID)
- if err != nil {
- return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
- }
- libUUID = pgtype.UUID{Bytes: lib, Valid: true}
- }
-
- limit := int32(50)
- offset := int32(0)
-
- searchPattern := "%" + query + "%"
-
- // Build params - conditionally add library_id filter
- partialParams := 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},
- LibraryID: libUUID, // May be invalid (empty)
- }
-
- partialResults, err := mh.db.SearchMediaItems(c.Request().Context(), partialParams)
-
- if err != nil && err != pgx.ErrNoRows {
- return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
- }
-
- if len(partialResults) > 0 {
- return c.JSON(http.StatusOK, partialResults)
- }
-
- // Fuzzy search also gets library_id filter
- fuzzyParams := database.SearchMediaItemsFuzzyParams{
- SearchQuery: pgtype.Text{String: query, Valid: true},
- UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
- Limit: pgtype.Int4{Int32: limit, Valid: true},
- Offset: pgtype.Int4{Int32: offset, Valid: true},
- LibraryID: libUUID, // May be invalid (empty)
- }
-
- fuzzyResults, err := mh.db.SearchMediaItemsFuzzy(c.Request().Context(), fuzzyParams)
-
- if err != nil && err != pgx.ErrNoRows {
- return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
- }
-
- if len(fuzzyResults) == 0 {
- return c.JSON(http.StatusNotFound, map[string]interface{}{
- "error": "no results found",
- "query": query,
- "results": []interface{}{},
- })
- }
-
- return c.JSON(http.StatusOK, fuzzyResults)
-}
-```
-
-**Why**: Both partial and fuzzy searches respect library filter for consistent UX.
-
----
-
-## Step 5: Remove Inline JS from Template
-
-### File: `templates/collections.templ`
-
-**Find the inline script block** (lines 255-520) and **delete it**.
-
-**Add after the `
` tag** (after line 116):
-```go
-
-
-
-
-
-
-```
-
-**Also update the CollectionDetail function signature** (around line 105):
-
-**Current:**
-```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 {
-```
-
----
-
-## Step 6: Add Toggle UI to Add Books Modal
-
-### File: `templates/collections.templ`
-
-**Find the Add Books modal content** (around line 228):
-
-**Current:**
-```go
-
-
- ...
-
Search and select books to add to this collection.
-
-
-
-
-```
-
-**Replace with:**
-```go
-
-
- ...
-
Search and select books to add to this collection.
-
-
-
-
-
-
-
-
-
-
-```
-
-**Note**: The `onchange="searchBooks()"` ensures the search re-runs when toggle changes.
-
----
-
-## Step 7: Add TypeScript Functions with WebSocket Support
-
-### File: `web/src/collections.ts`
-
-**Add at the end of the file**:
-
-```typescript
-// ============================================================================
-// Collection Detail Page - TypeScript with WebSocket Support
-// ============================================================================
-
-interface SearchBookResult {
- media_item_id: string;
- title: string;
- author: string | null;
- cover_image_path: string | null;
- library_id: string;
- library_name: string;
-}
-
-interface CollectionUpdateMessage {
- type: string;
- data: {
- collection_id: string;
- action: string;
- count?: number;
- book_id?: string;
- };
-}
-
-let collectionId = "";
-let libraryId = "";
-let selectedBooks = new Set
();
-let booksToRemove = new Set();
-let ws: WebSocket | null = null;
-
-// 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");
- }
- }
- }
-
- // Initialize WebSocket connection
- connectWebSocket();
-}
-
-// 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 "";
-}
-
-// WebSocket connection for real-time collection updates
-function connectWebSocket(): void {
- const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
- const token = localStorage.getItem("token");
- if (!token) return;
-
- const wsUrl = `${protocol}//${window.location.host}/ws/sync?token=${token}`;
-
- ws = new WebSocket(wsUrl);
-
- ws.onopen = (): void => {
- console.log("WebSocket connected");
- };
-
- ws.onmessage = (event: MessageEvent): void => {
- try {
- const message = JSON.parse(event.data) as CollectionUpdateMessage;
-
- if (message.type === "collection_updated" && message.data.collection_id === collectionId) {
- const actionText = message.data.action === "books_added"
- ? `Added ${message.data.count || 0} book(s)`
- : message.data.action === "book_removed"
- ? "Removed a book"
- : message.data.action === "books_bulk_removed"
- ? `Removed ${message.data.count || 0} book(s)`
- : "Collection updated";
-
- (window as any).showToast?.(actionText, "info");
-
- // Mitigation: Skip auto-reload if user is actively typing or interacting
- const activeElement = document.activeElement;
- const isUserActive = activeElement && (
- activeElement.tagName === "INPUT" ||
- activeElement.tagName === "TEXTAREA" ||
- activeElement.tagName === "SELECT" ||
- activeElement.getAttribute("contenteditable") === "true"
- );
-
- if (!isUserActive) {
- // Auto-reload after 1 second to see updates (only if user not actively typing)
- setTimeout(() => {
- location.reload();
- }, 1000);
- } else {
- // User is active - just show toast, don't reload
- // They'll see updates when they navigate away or manually refresh
- console.log("User actively typing - skipping auto-reload");
- }
- }
- } catch (error) {
- console.error("Failed to parse WebSocket message:", error);
- }
- };
-
- ws.onclose = (): void => {
- console.log("WebSocket disconnected, reconnecting in 5s...");
- setTimeout(connectWebSocket, 5000);
- };
-
- ws.onerror = (error: Event): void => {
- console.error("WebSocket error:", error);
- };
-}
-
-function backToCollections(): void {
- window.location.href = "/collections";
-}
-
-// 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 - 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");
- if (!token) {
- container.innerHTML = 'Authentication required
';
- return;
- }
-
- try {
- const response = await fetch(`/api/media-items/search?q=${encodeURIComponent(searchTerm)}${libraryFilter}`, {
- headers: { Authorization: `Bearer ${token}` },
- });
-
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}`);
- }
-
- const result = await response.json() as SearchBookResult[];
-
- if (result.length > 0) {
- let html = '';
- result.slice(0, 50).forEach((book) => {
- 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 += `
-
-
-

-
-
${book.title}
- ${authorHtml}
-
- ${libraryBadge}
-
- `;
- });
- html += "
";
- container.innerHTML = html;
- } else {
- container.innerHTML = 'No books found
';
- }
- } catch (error) {
- console.error("Search error:", 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");
- if (!token) {
- (window as any).showToast?.("Authentication required", "error");
- return;
- }
-
- 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();
- // Note: WebSocket will trigger page reload automatically
- } else {
- (window as any).showToast?.("Failed to add books", "error");
- }
- } catch (error) {
- console.error("Add books error:", 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");
- if (!token) {
- (window as any).showToast?.("Authentication required", "error");
- return;
- }
-
- 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");
- // Note: WebSocket will trigger page reload automatically
- } else {
- (window as any).showToast?.("Failed to remove book", "error");
- }
- } catch (error) {
- console.error("Remove book error:", 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");
- if (!token) {
- (window as any).showToast?.("Authentication required", "error");
- return;
- }
-
- 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 }),
- });
-
- if (response.ok) {
- const result = (await response.json()) as { removed: number };
- if (result.removed > 0) {
- (window as any).showToast?.(`Removed ${result.removed} book(s) from collection`, "success");
- // Note: WebSocket will trigger page reload automatically
- } else {
- (window as any).showToast?.("Failed to remove books", "error");
- }
- } else {
- (window as any).showToast?.("Failed to remove books", "error");
- }
- } catch (error) {
- console.error("Bulk remove error:", 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;
-(window as any).backToCollections = backToCollections;
-
-// Auto-initialize
-if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", initCollectionDetail);
-} else {
- initCollectionDetail();
-}
-```
-
----
-
-## Step 8: Update Handler to Pass libraryID to Template
-
-### File: `internal/router/frontend.go`
-
-**Find the collection detail handler** (around line 430-432):
-
-**Current:**
-```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)
-```
-
----
-
-## Step 9: Add Integration Tests
-
-### File: `cmd/server/tests/search_test.go`
-
-**Add to existing `search_test.go`** (recommended) OR create `cmd/server/tests/collections_test.go`:
-
-```go
-package main
-
-import (
- "bytes"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-
- "github.com/gorilla/websocket"
- "github.com/stretchr/testify/require"
-)
-
-// TestCollectionSearchLibraryFilter tests library_id filtering in search API
-func TestCollectionSearchLibraryFilter(t *testing.T) {
- setup := setupTestServer(t)
- client := &http.Client{}
-
- // Create two libraries with books via API
- lib1Resp := createLibrary(t, client, setup, "Library 1 - Search Test")
- lib2Resp := createLibrary(t, client, setup, "Library 2 - Search Test")
-
- // Add books to each library
- book1ID := createTestMediaItemIDInLibrary(t, client, setup, lib1Resp["id"].(string), "Harry Potter 1")
- book2ID := createTestMediaItemIDInLibrary(t, client, setup, lib2Resp["id"].(string), "Harry Potter 2")
-
- tests := []struct {
- name string
- query string
- libraryID string
- expectedCount int
- shouldContain string
- }{
- {
- name: "no filter - both books",
- query: "Harry",
- libraryID: "",
- expectedCount: 2,
- shouldContain: "", // Either book
- },
- {
- name: "filter library 1",
- query: "Harry",
- libraryID: lib1Resp["id"].(string),
- expectedCount: 1,
- shouldContain: book1ID,
- },
- {
- name: "filter library 2",
- query: "Harry",
- libraryID: lib2Resp["id"].(string),
- expectedCount: 1,
- shouldContain: book2ID,
- },
- {
- name: "invalid library_id",
- query: "Harry",
- libraryID: "00000000-0000-0000-0000-000000000000",
- expectedCount: 0,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- url := setup.Server.URL + "/api/media-items/search?q=" + tt.query
- if tt.libraryID != "" {
- url += "&library_id=" + tt.libraryID
- }
-
- req, _ := http.NewRequest("GET", url, nil)
- req.Header.Set("Authorization", "Bearer "+setup.Token)
-
- resp, err := client.Do(req)
- require.NoError(t, err)
- defer resp.Body.Close()
-
- var result []map[string]interface{}
- json.NewDecoder(resp.Body).Decode(&result)
-
- if tt.expectedCount > 0 {
- require.Equal(t, http.StatusOK, resp.StatusCode)
- require.Equal(t, tt.expectedCount, len(result))
- }
-
- if tt.shouldContain != "" {
- found := false
- for _, book := range result {
- if book["id"] == tt.shouldContain {
- found = true
- break
- }
- }
- require.True(t, found, "Expected book %s not found in results", tt.shouldContain)
- }
- })
- }
-}
-
-// Helper: createLibrary creates a library via API
-func createLibrary(t *testing.T, client *http.Client, setup *TestServerSetup, name string) map[string]interface{} {
- libReq := map[string]interface{}{
- "name": name,
- "description": "Test library",
- "type": "ebooks",
- }
- body, _ := json.Marshal(libReq)
-
- req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(body))
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Authorization", "Bearer "+setup.Token)
-
- resp, err := client.Do(req)
- require.NoError(t, err)
- defer resp.Body.Close()
- require.Equal(t, http.StatusCreated, resp.StatusCode)
-
- var result map[string]interface{}
- json.NewDecoder(resp.Body).Decode(&result)
- return result
-}
-
-// Helper: createTestMediaItemIDInLibrary creates a media item in specific library
-func createTestMediaItemIDInLibrary(t *testing.T, client *http.Client, setup *TestServerSetup, libraryID string, title string) string {
- mediaReq := map[string]interface{}{
- "library_id": libraryID,
- "title": title,
- "author": "Test Author",
- "file_path": "/tmp/test.epub",
- "file_size": 1024,
- "mime_type": "application/epub+zip",
- }
- body, _ := json.Marshal(mediaReq)
-
- req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body))
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Authorization", "Bearer "+setup.Token)
-
- resp, err := client.Do(req)
- require.NoError(t, err)
- defer resp.Body.Close()
- require.Equal(t, http.StatusCreated, resp.StatusCode)
-
- var result map[string]interface{}
- json.NewDecoder(resp.Body).Decode(&result)
- return result["id"].(string)
-}
-```
-
----
-
-### File: `cmd/server/tests/websocket_test.go` (Recommended)
-
-**Add to existing `websocket_test.go`** OR create new file:
-
-```go
-// TestWebSocketUserScopedBroadcast tests that broadcasts only go to the user who made changes
-func TestWebSocketUserScopedBroadcast(t *testing.T) {
- setup := setupTestServer(t)
- client := &http.Client{}
-
- // Use pre-created admin user (setup.Token) and regular user (setup.RegularToken)
- // Both users already created by setupTestServer()
-
- // Create a collection for admin user
- collectionReq := map[string]interface{}{
- "name": "Admin Collection",
- "description": "Test collection for WebSocket test",
- }
- collectionBody, _ := json.Marshal(collectionReq)
-
- collectionHTTP, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(collectionBody))
- collectionHTTP.Header.Set("Content-Type", "application/json")
- collectionHTTP.Header.Set("Authorization", "Bearer "+setup.Token)
-
- collectionResp, err := client.Do(collectionHTTP)
- require.NoError(t, err)
- defer collectionResp.Body.Close()
- require.Equal(t, http.StatusCreated, collectionResp.StatusCode)
-
- var collectionResult map[string]interface{}
- json.NewDecoder(collectionResp.Body).Decode(&collectionResult)
- collectionID := collectionResult["id"].(string)
-
- // Create a test book via API
- bookID := createTestMediaItemID(t, setup)
-
- // Connect admin user via WebSocket
- wsAdmin := connectWebSocketToServer(t, setup.Server.URL, setup.Token)
- defer wsAdmin.Close()
-
- // Connect regular user via WebSocket
- wsRegular := connectWebSocketToServer(t, setup.Server.URL, setup.RegularToken)
- defer wsRegular.Close()
-
- // Admin adds book to collection
- addReq := map[string]interface{}{
- "book_ids": []string{bookID},
- }
- addBody, _ := json.Marshal(addReq)
-
- addHTTP, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/books", bytes.NewBuffer(addBody))
- addHTTP.Header.Set("Content-Type", "application/json")
- addHTTP.Header.Set("Authorization", "Bearer "+setup.Token)
-
- addResp, err := client.Do(addHTTP)
- require.NoError(t, err)
- defer addResp.Body.Close()
- require.Equal(t, http.StatusNoContent, addResp.StatusCode)
-
- // Admin should receive collection_updated message
- msgAdmin := readWebSocketMessage(t, wsAdmin, 2*time.Second)
- if msgAdmin["type"] != "collection_updated" {
- t.Errorf("Admin should receive collection_updated, got %s", msgAdmin["type"])
- }
-
- // Regular user should NOT receive collection_updated message
- wsRegular.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
- _, _, err = wsRegular.ReadMessage()
- if err == nil {
- t.Errorf("Regular user should not receive collection_updated message")
- }
-}
-
-// Helper: connectWebSocketToServer establishes WebSocket connection with auth token
-func connectWebSocketToServer(t *testing.T, serverURL string, token string) *websocket.Conn {
- wsURL := "ws" + strings.TrimPrefix(serverURL, "http") + "/ws/sync?token=" + token
-
- ws, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
- require.NoError(t, err, "WebSocket connection should succeed")
- if resp != nil {
- resp.Body.Close()
- }
- require.NotNil(t, ws, "WebSocket connection should be established")
-
- // Wait for connection to be ready
- time.Sleep(100 * time.Millisecond)
-
- return ws
-}
-
-// Helper: readWebSocketMessage reads a message from WebSocket with timeout
-func readWebSocketMessage(t *testing.T, ws *websocket.Conn, timeout time.Duration) map[string]interface{} {
- ws.SetReadDeadline(time.Now().Add(timeout))
-
- _, message, err := ws.ReadMessage()
- require.NoError(t, err, "Should receive WebSocket message")
-
- var msg map[string]interface{}
- err = json.Unmarshal(message, &msg)
- require.NoError(t, err, "Message should be valid JSON")
-
- return msg
-}
-```
-
----
-
-## Step 10: Update Documentation
-
-### File: `docs/developer/api/media-items/search_media_items.md`
-
-**Update existing documentation to add library_id parameter:**
-
-1. **Add `library_id` to Query Parameters table:**
-
-```markdown
-| Parameter | Type | Required | Description |
-| --------- | ------- | -------- | ------------------------------------------- |
-| q | string | Yes | Search query (minimum 2 characters) |
-| library_id| string | No | Filter results to specific library (UUID) |
-| limit | integer | No | Number of results (default 20) |
-| offset | integer | No | Number to skip |
-```
-
-2. **Add example request showing library_id filter:**
-
-```markdown
-Search all libraries:
-
-```http
-GET /api/media-items/search?q=Harry+Potter&limit=20&offset=0
-Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
-```
-
-Search within a specific library:
-
-```http
-GET /api/media-items/search?q=Harry&library_id=123e4567-e89b-12d3-a456-426614174000&limit=20
-Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
-```
-```
-
-**Response:**
-
-Array of media items (partial match) or (fuzzy match):
-```json
-[
- {
- "id": "...",
- "title": "Harry Potter and the Sorcerer's Stone",
- "author": "J.K. Rowling",
- "library_id": "...",
- "library_name": "E-Books",
- ...
- }
-]
-```
-```
-
-### Bruno API Tests
-
-Create 3 separate request files in `bruno/media-items/`:
-
----
-
-### File: `bruno/media-items/Search All Libraries.yml`
-
-```yaml
-info:
- name: Search All Libraries
- type: http
- seq: 1
-
-http:
- method: GET
- url: '{{base_url}}/api/media-items/search'
- params:
- - name: q
- value: "harry"
- type: query
- disabled: false
- auth: inherit
-
-settings:
- encodeUrl: true
- timeout: 0
- followRedirects: true
- maxRedirects: 5
-
-docs: |-
- ## Search All Libraries
-
- Searches for media items across all libraries without filtering.
-
- **Method:** GET
-
- **Endpoint:** /api/media-items/search
-
- **Authentication:** Required (Bearer token)
-
- **Query Parameters:**
- - `q` (string, required): Search query (minimum 2 characters)
-
- **Response:** HTTP 200 (OK)
- ```json
- [
- {
- "id": "uuid",
- "title": "Harry Potter and the Sorcerer's Stone",
- "author": "J.K. Rowling",
- "library_id": "uuid",
- "library_name": "E-Books"
- }
- ]
- ```
-
- **Success Criteria:**
- - Status: 200
- - Returns array of media items from all libraries
- - Results match search query
-```
-
----
-
-### File: `bruno/media-items/Search Specific Library.yml`
-
-```yaml
-info:
- name: Search Specific Library
- type: http
- seq: 2
-
-http:
- method: GET
- url: '{{base_url}}/api/media-items/search'
- params:
- - name: q
- value: "harry"
- type: query
- disabled: false
- - name: library_id
- value: "{{libraryId}}"
- type: query
- disabled: false
- auth: inherit
-
-settings:
- encodeUrl: true
- timeout: 0
- followRedirects: true
- maxRedirects: 5
-
-docs: |-
- ## Search Specific Library
-
- Searches for media items within a specific library using the library_id filter.
-
- **Method:** GET
-
- **Endpoint:** /api/media-items/search
-
- **Authentication:** Required (Bearer token)
-
- **Query Parameters:**
- - `q` (string, required): Search query (minimum 2 characters)
- - `library_id` (string/UUID, required): Filter results to specific library
-
- **Response:** HTTP 200 (OK)
- ```json
- [
- {
- "id": "uuid",
- "title": "Harry Potter and the Sorcerer's Stone",
- "author": "J.K. Rowling",
- "library_id": "{{libraryId}}",
- "library_name": "My Library"
- }
- ]
- ```
-
- **Success Criteria:**
- - Status: 200
- - All results have `library_id` matching the filter
- - Results match search query
-
- **Test Setup:**
- - Set `libraryId` environment variable to a valid library UUID
-```
-
----
-
-### File: `bruno/media-items/Search Invalid Library ID.yml`
-
-```yaml
-info:
- name: Search Invalid Library ID
- type: http
- seq: 3
-
-http:
- method: GET
- url: '{{base_url}}/api/media-items/search'
- params:
- - name: q
- value: "test"
- type: query
- disabled: false
- - name: library_id
- value: "invalid-uuid"
- type: query
- disabled: false
- auth: inherit
-
-settings:
- encodeUrl: true
- timeout: 0
- followRedirects: true
- maxRedirects: 5
-
-docs: |-
- ## Search Invalid Library ID
-
- Tests error handling when an invalid library_id is provided.
-
- **Method:** GET
-
- **Endpoint:** /api/media-items/search
-
- **Authentication:** Required (Bearer token)
-
- **Query Parameters:**
- - `q` (string, required): Search query
- - `library_id` (string, invalid): Malformed UUID
-
- **Response:** HTTP 400 (Bad Request)
- ```json
- {
- "error": "invalid library_id"
- }
- ```
-
- **Success Criteria:**
- - Status: 400
- - Returns error message indicating invalid library_id
-
- **Edge Cases Tested:**
- - Malformed UUID (not valid UUID format)
- - Validates input sanitization
-```
-
----
-
-## Summary of Changes
-
-| Step | File | Lines Changed | Purpose |
-|------|------|---------------|---------|
-| 1 | `internal/sync/websocket.go` | +20 | Add user-scoped broadcast method |
-| 2 | `internal/handlers/collections.go` | ~9 | Use user-scoped broadcasts |
-| 3 | `internal/database/queries.sql` | ~4 | Add library_id filter to SQL |
-| 3b | `internal/database/queries.sql.go` | auto | Regenerate with sqlc |
-| 4 | `internal/handlers/media.go` | ~30 | Add library_id param to search |
-| 5 | `templates/collections.templ` | -265 + 25 | Remove inline JS, add data attributes |
-| 6 | `templates/collections.templ` | ~10 | Add toggle UI with onchange |
-| 7 | `web/src/collections.ts` | +~320 | Add TypeScript with WebSocket |
-| 8 | `internal/router/frontend.go` | ~3 | Pass libraryID to template |
-| 9 | `cmd/server/tests/search_test.go` | +~150 | Integration tests (library filter) |
-| 9 | `cmd/server/tests/websocket_test.go` | +~80 | Integration tests (user-scoped broadcast) |
-| 10 | `docs/developer/api/media-items/search_media_items.md` | +~15 | Update API docs with library_id |
-| 10 | `bruno/media-items/Search All Libraries.yml` | +~50 | Bruno test: search without filter |
-| 10 | `bruno/media-items/Search Specific Library.yml` | +~55 | Bruno test: search with library filter |
-| 10 | `bruno/media-items/Search Invalid Library ID.yml` | +~50 | Bruno test: invalid library_id error |
-
----
-
-## Testing Checklist
-
-### Manual Testing
-
-- [ ] 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
-- [ ] Check/uncheck toggle - search re-runs automatically
-- [ ] Add book to collection - toast appears, page reloads via WebSocket
-- [ ] Open same collection in second tab - first tab shows update when second tab adds book
-- [ ] Remove book from collection - toast appears, page reloads
-- [ ] Search within collection - books filter client-side
-- [ ] **Auto-reload mitigation test**: While typing in search box, have another tab add books - verify no reload occurs, toast still shows
-
-### Automated Testing
-
-- [ ] `go test ./cmd/server/tests -run TestCollectionSearchLibraryFilter`
-- [ ] `go test ./cmd/server/tests -run TestWebSocketUserScopedBroadcast`
-- [ ] Bruno tests: `search-with-library-filter.yml`
-- [ ] Verify no WebSocket messages go to wrong users
-
----
-
-## Notes
-
-- **WebSocket real-time sync is preserved** for multi-device/tab updates
-- **User-scoped broadcasts** ensure privacy (User A doesn't see User B's collection updates)
-- **Toggle default is "checked"** (filter by library) when library_id is present
-- **Search respects library filter** in both partial and fuzzy searches for consistency
-- **Auto-reload mitigation**: WebSocket handler checks if user is actively typing (INPUT/TEXTAREA/SELECT/contenteditable) and skips reload to prevent data loss
-- **All changes follow PROJECT_GUIDELINES.md**: TypeScript only, TailwindCSS only, procedural style
-- **Progressive enhancement maintained**: Page works without JS (server-side rendered)