feat: implement collection library filter with WebSocket improvements and test coverage
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.
This commit is contained in:
+16
-267
@@ -102,7 +102,7 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
</html>
|
||||
}
|
||||
|
||||
templ CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo) {
|
||||
templ CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo, libraryID string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -232,6 +232,13 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
<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"
|
||||
@@ -252,272 +259,14 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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';
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/sync`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = function() {
|
||||
console.log('WebSocket connected');
|
||||
};
|
||||
|
||||
ws.onmessage = function(event) {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.type === 'collection_updated' && message.data.collection_id === collectionId) {
|
||||
showToast(`Collection updated: ${message.data.action} (${message.data.count} books)`, 'info');
|
||||
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function() {
|
||||
console.log('WebSocket disconnected, reconnecting in 5s...');
|
||||
setTimeout(connectWebSocket, 5000);
|
||||
};
|
||||
|
||||
ws.onerror = function(error) {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
}
|
||||
|
||||
connectWebSocket();
|
||||
|
||||
function filterCollectionBooks() {
|
||||
const searchTerm = document.getElementById('collection-search').value.toLowerCase();
|
||||
const booksContainer = document.getElementById('books-container');
|
||||
const bookCards = booksContainer.children;
|
||||
|
||||
let visibleCount = 0;
|
||||
for (let i = 0; i < bookCards.length; i++) {
|
||||
const card = bookCards[i];
|
||||
if (card.id === 'empty-state') continue;
|
||||
|
||||
const title = card.querySelector('.font-semibold')?.textContent.toLowerCase() || '';
|
||||
const author = card.querySelector('.text-sm')?.textContent.toLowerCase() || '';
|
||||
|
||||
const matches = title.includes(searchTerm) || author.includes(searchTerm);
|
||||
|
||||
if (matches || searchTerm === '') {
|
||||
card.style.display = '';
|
||||
visibleCount++;
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showAddBooksModal() {
|
||||
document.getElementById('add-books-modal').classList.remove('hidden');
|
||||
selectedBooks.clear();
|
||||
document.getElementById('book-results').innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Enter at least 2 characters to search.</p>';
|
||||
}
|
||||
|
||||
function hideAddBooksModal() {
|
||||
document.getElementById('add-books-modal').classList.add('hidden');
|
||||
document.getElementById('book-search').value = '';
|
||||
document.getElementById('book-results').innerHTML = '';
|
||||
selectedBooks.clear();
|
||||
}
|
||||
|
||||
function searchBooks() {
|
||||
const searchTerm = document.getElementById('book-search').value;
|
||||
const container = document.getElementById('book-results');
|
||||
|
||||
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>';
|
||||
|
||||
fetch(`/api/media-items/search?q=${encodeURIComponent(searchTerm)}`, {
|
||||
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.data && result.data.length > 0) {
|
||||
let html = '<div class="space-y-2">';
|
||||
result.data.slice(0, 50).forEach(book => {
|
||||
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>` : '';
|
||||
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>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
if (result.data.length > 50) {
|
||||
html += '<p class="text-sm mt-2" style="color: var(--text-secondary)">Showing first 50 of ' + result.data.length + ' results</p>';
|
||||
}
|
||||
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>';
|
||||
});
|
||||
}
|
||||
|
||||
function toggleBookSelection(bookId) {
|
||||
if (selectedBooks.has(bookId)) {
|
||||
selectedBooks.delete(bookId);
|
||||
} else {
|
||||
selectedBooks.add(bookId);
|
||||
}
|
||||
searchBooks();
|
||||
}
|
||||
|
||||
function addSelectedBooks() {
|
||||
if (selectedBooks.size === 0) {
|
||||
showToast('Please select at least one book', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const bookIds = Array.from(selectedBooks);
|
||||
|
||||
fetch(`/api/collections/${collectionId}/books`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
},
|
||||
body: JSON.stringify({ book_ids: bookIds })
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
showToast(`Added ${bookIds.length} book(s) to collection`, 'success');
|
||||
hideAddBooksModal();
|
||||
location.reload();
|
||||
} else {
|
||||
showToast('Failed to add books', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showToast('Failed to add books', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function removeBook(bookId) {
|
||||
if (!confirm('Remove this book from the collection?')) return;
|
||||
|
||||
fetch(`/api/collections/${collectionId}/books/${bookId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
showToast('Book removed from collection', 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
showToast('Failed to remove book', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showToast('Failed to remove book', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleBookForRemoval(bookId) {
|
||||
if (booksToRemove.has(bookId)) {
|
||||
booksToRemove.delete(bookId);
|
||||
} else {
|
||||
booksToRemove.add(bookId);
|
||||
}
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
const count = booksToRemove.size;
|
||||
const countSpan = document.getElementById('selected-count');
|
||||
const removeBtn = document.getElementById('bulk-remove-btn');
|
||||
|
||||
if (count > 0) {
|
||||
countSpan.textContent = count + ' selected';
|
||||
countSpan.classList.remove('hidden');
|
||||
removeBtn.disabled = false;
|
||||
} else {
|
||||
countSpan.classList.add('hidden');
|
||||
removeBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedBooks() {
|
||||
if (booksToRemove.size === 0) {
|
||||
showToast('No books selected', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Remove ${booksToRemove.size} book(s) from the collection?`)) return;
|
||||
|
||||
const bookIds = Array.from(booksToRemove);
|
||||
|
||||
fetch(`/api/collections/${collectionId}/books/bulk-remove`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
},
|
||||
body: JSON.stringify({ book_ids: bookIds })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.removed > 0) {
|
||||
showToast(`Removed ${result.removed} book(s) from collection`, 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
showToast('Failed to remove books', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showToast('Failed to remove books', 'error');
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showToast('Failed to remove book', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
</script>
|
||||
</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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user