Files
bookhoard/templates/collections.templ
T
john-okeefe 508bfb0387 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)
2026-02-01 00:52:09 -05:00

520 lines
25 KiB
Templ
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package templates
templ Collection(user User, collections []CollectionData) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Collections - Bookmann</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/header.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/collections")
<div class="max-w-7xl mx-auto 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>
<button onclick="showCreateModal()" class="btn-primary px-4 py-2 rounded-lg">
New Collection
</button>
</div>
<div id="collections-container" 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 onclick="showCreateModal()" class="btn-primary px-4 py-2 rounded-lg">
Create Your First Collection
</button>
</div>
}
for _, col := range collections {
<div class="card p-6 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary); border-color: { col.Color }; border-left-width: 4px; border-left-style: solid;"
onclick="viewCollection('{ col.ID }')">
<div class="flex justify-between items-start mb-4">
<div class="text-3xl">{ col.Icon }</div>
<div class="flex space-x-2">
<button onclick="event.stopPropagation(); editCollection('{ col.ID }')"
class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
✏️
</button>
<button onclick="event.stopPropagation(); deleteCollection('{ col.ID }')"
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 id="create-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-md 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)">Create Collection</h2>
<button onclick="hideCreateModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div>
<form id="create-form" onsubmit="handleCreate(event)">
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name</label>
<input type="text" id="collection-name" required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="My Reading List">
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
<textarea id="collection-description"
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="Optional description"
rows="3"></textarea>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
<div class="flex gap-2">
<button type="button" onclick="selectColor('#7aa2f7')" class="w-8 h-8 rounded-full color-option" style="background-color: #7aa2f7;"></button>
<button type="button" onclick="selectColor('#f7768e')" class="w-8 h-8 rounded-full color-option" style="background-color: #f7768e;"></button>
<button type="button" onclick="selectColor('#e0af68')" class="w-8 h-8 rounded-full color-option" style="background-color: #e0af68;"></button>
<button type="button" onclick="selectColor('#9ece6a')" class="w-8 h-8 rounded-full color-option" style="background-color: #9ece6a;"></button>
<button type="button" onclick="selectColor('#7dcfff')" class="w-8 h-8 rounded-full color-option" style="background-color: #7dcfff;"></button>
<button type="button" onclick="selectColor('#bb9af7')" class="w-8 h-8 rounded-full color-option" style="background-color: #bb9af7;"></button>
</div>
<input type="hidden" id="collection-color" value="#7aa2f7">
</div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="hideCreateModal()" class="btn-secondary px-4 py-2 rounded-lg">
Cancel
</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
Create Collection
</button>
</div>
</form>
</div>
</div>
<script>
let selectedColor = '#7aa2f7';
function showCreateModal() {
document.getElementById('create-modal').classList.remove('hidden');
}
function hideCreateModal() {
document.getElementById('create-modal').classList.add('hidden');
document.getElementById('create-form').reset();
selectedColor = '#7aa2f7';
updateColorSelection();
}
function selectColor(color) {
selectedColor = color;
document.getElementById('collection-color').value = color;
updateColorSelection();
}
function updateColorSelection() {
document.querySelectorAll('.color-option').forEach(btn => {
btn.style.outline = 'none';
btn.style.outlineOffset = '0';
});
const selectedBtn = document.querySelector(`.color-option[onclick="selectColor('${selectedColor}')"]`);
if (selectedBtn) {
selectedBtn.style.outline = '3px solid var(--text-primary)';
selectedBtn.style.outlineOffset = '3px';
}
}
function handleCreate(event) {
event.preventDefault();
const data = {
name: document.getElementById('collection-name').value,
description: document.getElementById('collection-description').value,
color: selectedColor,
icon: '📚'
};
fetch('/api/collections', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
showToast('Collection created successfully', 'success');
hideCreateModal();
location.reload();
})
.catch(error => {
showToast('Failed to create collection', 'error');
});
}
function viewCollection(id) {
window.location.href = `/collections/${id}`;
}
function editCollection(id) {
showToast('Edit feature coming soon!', 'info');
}
function deleteCollection(id) {
if (!confirm('Are you sure you want to delete this collection?')) return;
fetch(`/api/collections/${id}`, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
})
.then(response => {
if (response.ok) {
showToast('Collection deleted successfully', 'success');
location.reload();
} else {
showToast('Failed to delete collection', 'error');
}
})
.catch(error => {
showToast('Failed to delete collection', 'error');
});
}
function logout() {
localStorage.removeItem('token');
window.location.href = '/login';
}
</script>
</body>
</html>
}
templ CollectionDetail(user User, collection CollectionDetailData, books []BookData) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{ collection.Name } - Bookmann</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/header.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/collections")
<div class="max-w-7xl mx-auto 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">
<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 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 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-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>
}
<button onclick="removeBook('{ book.MediaItemID }')"
class="mt-2 px-3 py-1 text-sm border rounded hover:opacity-80"
style="border-color: var(--border); color: var(--text-secondary);">
Remove
</button>
</div>
</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>
<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>
<script>
let collectionId = '{ collection.ID }';
let selectedBooks = new Set();
let booksToRemove = new Set();
function backToCollections() {
window.location.href = '/collections';
}
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';
}
renderBooks();
</script>
</body>
</html>
}