Files
bookhoard/templates/collections.templ
T
john-okeefe 40c732481a feat(collections): add real-time updates via WebSocket (Limitation #4)
Implement real-time collection updates when books are added/removed:

Backend Changes:
- Added connManager to CollectionHandler struct
- Updated constructor to accept ConnectionManager
- Updated all NewCollectionHandler() calls in ebook.go and main.go
- Added WebSocket broadcasts in AddBooks() handler
- Added WebSocket broadcasts in BulkRemoveBooks() handler
- Broadcasts collection_updated events with:
  - collection_id: Which collection changed
  - action: books_added or books_removed
  - book_ids: Array of affected book IDs
  - count: Number of books changed

Frontend Changes:
- Added WebSocket connection in collections UI
- connectWebSocket() establishes connection to /ws/sync
- Listens for collection_updated events
- Shows toast notification on collection change
- Auto-reloads page after 1 second to show updated book list
- Auto-reconnect on disconnect (5s delay)
- Error handling for WebSocket failures

WebSocket Event Format:
{
  "type": "collection_updated",
  "timestamp": "2026-02-01T12:00:00Z",
  "data": {
    "collection_id": "uuid",
    "action": "books_added",
    "book_ids": ["uuid1", "uuid2"],
    "count": 2
  }
}

User Experience:
- When another user adds books to a collection, all connected clients see:
  1. Toast notification: "Collection updated: books_added (2 books)"
  2. Page auto-refreshes after 1 second
  3. Updated book list displays
- Same for book removal
- Works across multiple browser tabs/devices
- No manual refresh needed

Technical Notes:
- Broadcasts to ALL connected WebSocket clients
- Client-side filtering by collection_id
- Existing progress/conflict broadcasts continue to work
- Connection manager handles broadcast distribution

Resolves Limitation #4: Real-time Collection Updates
2026-02-01 00:54:41 -05:00

595 lines
28 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 }
];
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';
}
renderBooks();
</script>
</body>
</html>
}