Files
bookhoard/templates/collections.templ
T
john-okeefe 4bc7a842b5 feat(collections): add collection-specific search/filter (Limitation #3)
Add search and filter within a collection:

Frontend Implementation:
- Search input box in collection detail toolbar
- filterCollectionBooks() JavaScript function
- Real-time filtering of books by title and author
- Case-insensitive search
- Hides non-matching book cards
- Shows all books when search is cleared

How It Works:
- AllBooks array contains book data from server render
- On keyup, filters books by title and author
- Toggles display property on book cards
- Empty state has ID and is not hidden
- Pure client-side filtering (no server round-trips)

User Experience:
- Instant search results as user types
- No page reload required
- Works with existing multi-select for bulk operations
- Search box always visible in toolbar

Resolves Limitation #3: Collection-Specific Search
2026-02-01 00:52:45 -05:00

560 lines
27 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">
<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 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();
let allBooks = [
{ range books }{
{
id: '{ book.MediaItemID }',
title: '{ book.Title }',
author: '{ book.Author }',
cover: '{ book.CoverImagePath }'
},
}{ end }
];
function backToCollections() {
window.location.href = '/collections';
}
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';
}
renderBooks();
</script>
</body>
</html>
}