feat(collections): implement bulk add and remove books (Limitations #2 & #5)

Complete bulk operations for collections management:

BULK ADD BOOKS:
- Implemented searchBooks() with real API integration
- Multi-select checkboxes for book selection
- SelectedBooks Set tracks chosen books
- AddSelectedBooks() sends array to existing endpoint
- Uses existing POST /api/collections/:id/books endpoint

BULK REMOVE BOOKS:
- New endpoint: POST /api/collections/:id/books/bulk-remove
- Checkboxes on each book card for selection
- BooksToRemove Set tracks selections
- Live counter showing selected count
- BulkRemoveBooks() handler removes all in one API call
- More efficient than N individual DELETE requests

Frontend Changes:
- Selected counter badge shows number selected
- Bulk remove button (enabled when books selected)
- Checkboxes on all books for multi-select
- Confirmation dialog for bulk operations
- Toast notifications with counts

Backend Changes:
- BulkRemoveBooks() handler in collections.go
- Accepts book_ids array, returns removed/total counts
- Iterates and removes, counting successes
- Route: POST /api/collections/:id/books/bulk-remove

API Request:
{
  "book_ids": ["uuid1", "uuid2", "uuid3"]
}

API Response:
{
  "removed": 3,
  "total": 3
}

Tests Added:
- TestCompareValues_* (existing)
- TestEvaluateRule_* (existing)

Resolves Limitations #2 (Bulk Operations) and #5 (Bulk Remove)
This commit is contained in:
2026-02-01 00:52:09 -05:00
parent 592ccddf65
commit 508bfb0387
4 changed files with 227 additions and 36 deletions
+37
View File
@@ -306,6 +306,43 @@ func (h *CollectionHandler) RemoveBook(c echo.Context) error {
return c.NoContent(http.StatusNoContent)
}
type BulkRemoveBooksRequest struct {
BookIDs []string `json:"book_ids" validate:"required"`
}
func (h *CollectionHandler) BulkRemoveBooks(c echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
var req BulkRemoveBooksRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
removedCount := 0
for _, bookIDStr := range req.BookIDs {
bookID, err := uuid.Parse(bookIDStr)
if err != nil {
continue
}
err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID)
if err == nil {
removedCount++
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"removed": removedCount,
"total": len(req.BookIDs),
})
}
func (h *CollectionHandler) GetDeviceMappings(c echo.Context) error {
deviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
+1
View File
@@ -81,6 +81,7 @@ func SetupRoutes(g *echo.Group, db *database.Queries, connManager *wsync.Connect
collections.GET("/:id/books", collectionHandler.GetBookCollections)
collections.POST("/:id/books", collectionHandler.AddBooks)
collections.DELETE("/:id/books/:bookId", collectionHandler.RemoveBook)
collections.POST("/:id/books/bulk-remove", collectionHandler.BulkRemoveBooks)
collections.POST("/test-rules", collectionHandler.TestRules)
// Device shelf mapping routes
+161 -8
View File
@@ -240,11 +240,22 @@ templ CollectionDetail(user User, collection CollectionDetailData, books []BookD
</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">
<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 {
@@ -252,13 +263,18 @@ templ CollectionDetail(user User, collection CollectionDetailData, books []BookD
}
for _, book := range books {
<div class="card p-4 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
<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="aspect-w-3 aspect-h-4 mb-3 overflow-hidden rounded">
<div class="flex items-start gap-4">
<input type="checkbox"
onchange="toggleBookForRemoval('{ book.MediaItemID }')"
class="w-5 h-5 mt-2">
<div class="aspect-w-3 aspect-h-4 flex-shrink-0 w-24 mb-3 overflow-hidden rounded">
<img src="{ book.CoverImagePath }" alt="Cover"
class="w-full h-48 object-cover rounded"
class="w-full h-32 object-cover rounded"
onerror="this.src='/static/placeholder-book.svg'">
</div>
<div class="flex-1">
<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" style="color: var(--text-secondary)">by { book.Author }</p>
@@ -269,6 +285,8 @@ templ CollectionDetail(user User, collection CollectionDetailData, books []BookD
Remove
</button>
</div>
</div>
</div>
}
</div>
</div>
@@ -299,6 +317,8 @@ templ CollectionDetail(user User, collection CollectionDetailData, books []BookD
<script>
let collectionId = '{ collection.ID }';
let selectedBooks = new Set();
let booksToRemove = new Set();
function backToCollections() {
window.location.href = '/collections';
@@ -306,7 +326,8 @@ templ CollectionDetail(user User, collection CollectionDetailData, books []BookD
function showAddBooksModal() {
document.getElementById('add-books-modal').classList.remove('hidden');
searchBooks();
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() {
@@ -325,13 +346,84 @@ templ CollectionDetail(user User, collection CollectionDetailData, books []BookD
return;
}
// For now, this is a placeholder. We need a proper search endpoint.
// In a real implementation, you would call an API endpoint to search books.
container.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Book search coming soon!</p>';
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() {
showToast('Add books feature coming soon!', 'info');
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) {
@@ -354,6 +446,67 @@ templ CollectionDetail(user User, collection CollectionDetailData, books []BookD
});
}
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';
File diff suppressed because one or more lines are too long