This commit adds comprehensive functionality for filtering collections by library, improves WebSocket real-time updates with user activity detection, and adds extensive test coverage. ## Core Features ### Collection Library Filter - Added library_id parameter to media-items search API - Collections can now be filtered by specific library - Toggle UI component for enabling/disabling library filter - Default state is "checked" when library_id is present - Consistent behavior across partial and fuzzy search modes ### WebSocket Auto-Reload Mitigation - Added user activity detection to prevent disruptive page reloads - Checks if user is actively typing in INPUT/TEXTAREA/SELECT elements - Skips auto-reload when user is interacting with form elements - Toast notifications still show for awareness - Prevents data loss during editing operations ## Implementation Changes ### Backend - internal/database/queries.sql.go: Added library filter support to search queries - internal/handlers/media.go: Enhanced search with library_id parameter validation - internal/handlers/collections.go: Updated collection handlers with library filtering - internal/sync/websocket.go: Improved broadcast mechanism with user-scoped updates - internal/router/frontend.go: Pass libraryID to collection templates ### Frontend - templates/collections.templ: Added library filter toggle UI component - web/src/collections.ts: TypeScript implementation with WebSocket integration - templates/collections_templ.go: Generated template code ### Testing - cmd/server/tests/search_test.go: Added TestCollectionSearchLibraryFilter - cmd/server/tests/websocket_test.go: Added TestWebSocketUserScopedBroadcast - New helper functions for creating libraries and media items via API - Comprehensive test coverage for library filtering and user-scoped broadcasts ## API Documentation Updates ### Bruno Tests (Comprehensive Documentation) - bruno/collections/*: Added detailed API documentation for all collection endpoints - bruno/devices/*: Added device management and sync API documentation - bruno/devices/kobo/api.yml: Kobo-specific sync protocol docs - bruno/devices/koreader/api.yml: KOReader-specific sync protocol docs - bruno/opds/*: Added OPDS feed and download endpoint documentation - bruno/library/browse-folders.yml: Library folder browsing API docs ### New Bruno Tests - bruno/media-items/Search All Libraries.yml: Test search without library filter - bruno/media-items/Search Specific Library.yml: Test search with library filter - bruno/media-items/Search Invalid Library ID.yml: Test error handling ## Documentation - docs/developer/api/media-items/search_media_items.md: Updated with library_id parameter - IMPLEMENTATION_COLLECTION_FIX.md: Comprehensive implementation guide with test scenarios ## Testing ### Integration Tests - Library filter tests verify correct filtering across multiple libraries - Invalid library_id tests ensure proper error handling - WebSocket tests verify user-scoped broadcast behavior - User A no longer receives User B's collection updates ### Manual Testing Scenarios - Open collection in multiple tabs - updates propagate correctly - Type in search box while another tab adds books - no disruptive reload - Add/remove books from collection - toast notifications appear - Toggle library filter - results update dynamically ## Technical Details - WebSocket broadcasts are now user-scoped for privacy - Active element detection uses tagName and contenteditable attributes - Library ID validation uses UUID format checking - Progressive enhancement maintained - page works without JavaScript - All changes follow PROJECT_GUIDELINES.md conventions - TypeScript only for frontend logic - TailwindCSS only for styling - Procedural programming style throughout ## Breaking Changes None - all changes are additive and backward compatible.
273 lines
10 KiB
Templ
273 lines
10 KiB
Templ
package templates
|
||
|
||
import "bookhoard/internal/handlers"
|
||
|
||
templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<title>Collections - Bookhoard</title>
|
||
<script src="/static/htmx.min.js"></script>
|
||
<script src="/static/toast.js"></script>
|
||
<script src="/static/collections.js"></script>
|
||
<link href="/static/style.css" rel="stylesheet"/>
|
||
</head>
|
||
<body class="theme-{ user.Theme }">
|
||
@Header(user, "/collections")
|
||
<!-- Modal Container -->
|
||
<div id="modal-container"></div>
|
||
<!-- Actual container page -->
|
||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||
<div class="mb-8 flex justify-between items-center">
|
||
<div>
|
||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">My Collections</h1>
|
||
<p style="color: var(--text-secondary)">Organize your books into custom collections</p>
|
||
</div>
|
||
<div class="flex gap-3">
|
||
<button
|
||
hx-get="/collections/restore-modal"
|
||
hx-target="#modal-container"
|
||
hx-swap="innerHTML"
|
||
class="btn-secondary px-4 py-2 rounded-lg"
|
||
>
|
||
🔄 Restore System
|
||
</button>
|
||
<button
|
||
hx-get="/collections/create-modal"
|
||
hx-target="#modal-container"
|
||
hx-swap="innerHTML"
|
||
class="btn-primary px-4 py-2 rounded-lg"
|
||
>
|
||
➕ New Collection
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="collections-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
if len(collections) == 0 {
|
||
<div class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
|
||
<div class="text-6xl mb-4">📚</div>
|
||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
|
||
<p class="mb-4">Create collections to organize your books</p>
|
||
<button
|
||
hx-get="/collections/create-modal"
|
||
hx-target="#modal-container"
|
||
hx-swap="innerHTML"
|
||
class="btn-primary px-4 py-2 rounded-lg"
|
||
>
|
||
Create Your First Collection
|
||
</button>
|
||
</div>
|
||
}
|
||
for _, col := range collections {
|
||
<div onclick="navigateToCollection(this)" data-href={ "/collections/" + col.ID } class="block">
|
||
<div
|
||
class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
|
||
style="background-color: var(--bg-secondary);"
|
||
data-color={ col.Color }
|
||
>
|
||
<div class="flex justify-between items-start mb-4">
|
||
<div class="text-3xl">{ col.Icon }</div>
|
||
<div class="flex space-x-2">
|
||
<button
|
||
hx-get={ "/collections/" + col.ID + "/edit-modal" }
|
||
hx-target="#modal-container"
|
||
hx-swap="innerHTML"
|
||
class="p-2 hover:opacity-80 rounded"
|
||
style="color: var(--text-secondary); background-color: var(--bg-primary);"
|
||
>
|
||
✏️
|
||
</button>
|
||
<button
|
||
hx-delete={ "/api/collections/" + col.ID + "" }
|
||
hx-redirect="/collections"
|
||
hx-confirm="Are you sure you want to delete this collection?"
|
||
class="p-2 hover:opacity-80 rounded"
|
||
style="color: var(--text-secondary); background-color: var(--bg-primary);"
|
||
>
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ col.Name }</h3>
|
||
<p class="text-sm mb-4" style="color: var(--text-secondary)">{ col.Description }</p>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
@ErrorToast(errorMessage)
|
||
</body>
|
||
</html>
|
||
}
|
||
|
||
templ CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo, libraryID string) {
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<title>{ collection.Name } - Bookhoard</title>
|
||
<script src="/static/htmx.min.js"></script>
|
||
<script src="/static/toast.js"></script>
|
||
<link href="/static/style.css" rel="stylesheet"/>
|
||
</head>
|
||
<body class="theme-{ user.Theme }">
|
||
@Header(user, "/collections")
|
||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||
<div class="mb-6">
|
||
<button onclick="backToCollections()" class="btn-secondary px-4 py-2 rounded-lg mb-4">
|
||
← Back to Collections
|
||
</button>
|
||
<div class="flex items-center gap-4">
|
||
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
|
||
<div>
|
||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||
<p style="color: var(--text-secondary)">{ collection.Description }</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mb-6 flex justify-between items-center">
|
||
<div class="flex items-center gap-4">
|
||
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Books in this Collection</h2>
|
||
<span id="selected-count" class="hidden px-3 py-1 text-sm rounded" style="background-color: var(--accent); color: var(--bg-primary);">
|
||
0 selected
|
||
</span>
|
||
</div>
|
||
<div class="flex gap-3">
|
||
<div class="flex-1 max-w-md">
|
||
<input
|
||
type="text"
|
||
id="collection-search"
|
||
placeholder="Search within collection..."
|
||
onkeyup="filterCollectionBooks()"
|
||
class="w-full px-4 py-2 border rounded-lg"
|
||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||
/>
|
||
</div>
|
||
<button
|
||
id="bulk-remove-btn"
|
||
onclick="removeSelectedBooks()"
|
||
disabled
|
||
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
🗑️ Remove Selected
|
||
</button>
|
||
<button onclick="showAddBooksModal()" class="btn-primary px-4 py-2 rounded-lg">
|
||
➕ Add Books
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||
if len(books) == 0 {
|
||
<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
|
||
}
|
||
for _, book := range books {
|
||
<div
|
||
class="card p-4 rounded-lg border hover:shadow-lg transition-shadow"
|
||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||
>
|
||
<div class="flex gap-4">
|
||
<!-- Checkbox and Book Info -->
|
||
<div class="flex-shrink-0 pt-1">
|
||
<input
|
||
type="checkbox"
|
||
onchange="toggleBookForRemoval('{ book.MediaItemID }')"
|
||
class="w-5 h-5"
|
||
/>
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<h3
|
||
class="font-semibold text-lg mb-1 line-clamp-2"
|
||
style="color: var(--text-primary)"
|
||
>
|
||
{ book.Title }
|
||
</h3>
|
||
if book.Author != "" {
|
||
<p
|
||
class="text-sm line-clamp-1"
|
||
style="color: var(--text-secondary)"
|
||
>
|
||
by { book.Author }
|
||
</p>
|
||
}
|
||
</div>
|
||
<!-- Book Cover -->
|
||
<div class="flex-shrink-0 w-16 sm:w-20">
|
||
if book.CoverImagePath != "" {
|
||
<img
|
||
src={ book.CoverImagePath }
|
||
alt="Cover"
|
||
class="w-full aspect-[3/4] object-cover rounded shadow-md"
|
||
onerror="this.src='/static/placeholder-book.svg'"
|
||
/>
|
||
} else {
|
||
<img
|
||
src="/static/placeholder-book.svg"
|
||
alt="Cover"
|
||
class="w-full aspect-[3/4] object-cover rounded shadow-md"
|
||
/>
|
||
}
|
||
</div>
|
||
</div>
|
||
<!-- Remove Button -->
|
||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||
<button
|
||
onclick="removeBook('{ book.MediaItemID }')"
|
||
class="px-3 py-1 text-sm border rounded hover:opacity-80"
|
||
style="border-color: var(--border); color: var(--text-secondary);"
|
||
>
|
||
🗑️ Remove from Collection
|
||
</button>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
<div id="add-books-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
||
<div class="card rounded-lg p-6 w-full max-w-2xl mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||
<div class="flex justify-between items-center mb-6">
|
||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Books to Collection</h2>
|
||
<button onclick="hideAddBooksModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||
</div>
|
||
<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" onchange="searchBooksForCollection()"/>
|
||
<span>Only show books from this library</span>
|
||
</label>
|
||
</div>
|
||
<div class="mb-4">
|
||
<input
|
||
type="text"
|
||
id="book-search"
|
||
placeholder="Search books..."
|
||
class="w-full px-4 py-2 border rounded-lg"
|
||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||
/>
|
||
</div>
|
||
<div id="book-results" class="max-h-64 overflow-y-auto mb-4"></div>
|
||
<div class="flex justify-end space-x-3">
|
||
<button type="button" onclick="hideAddBooksModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
||
Cancel
|
||
</button>
|
||
<button type="button" onclick="addSelectedBooks()" class="btn-primary px-4 py-2 rounded-lg">
|
||
Add Selected Books
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
<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>
|
||
</html>
|
||
}
|