refactor(collections): move WebSocket from TypeScript to template with server-side token

- Move WebSocket connection logic from collections.ts to collections.templ template
- Inject JWT token directly into WebSocket URL from server-side User.Token
- Remove createWebSocket import, ws variable, and connectWebSocket() function
- Remove unused CollectionUpdateMessage interface
- Embed complete WebSocket message handling in template script tag

Changes to collections.templ:
- Add inline <script> with WebSocket connection using server-injected token
- Implement collection_updated message handler with toast notifications
- Include 5-second auto-reconnection on disconnect
- Add user activity detection to skip auto-reload when actively typing
- Initialize collectionId and libraryId from data attributes

Changes to collections.ts:
- Remove createWebSocket import
- Remove ws variable declaration
- Remove connectWebSocket() function and CollectionUpdateMessage interface
- Keep all other collection functionality (CRUD operations, modals, search, etc.)

This refactoring eliminates client-side localStorage dependencies for
WebSocket authentication, making the collections page consistent with the
SSR architecture. The token is now injected server-side on every page
load, ensuring WebSocket connections always work when the user is
authenticated via HttpOnly cookie. Auto-reconnection and smart reload
behavior is preserved for optimal UX.
This commit is contained in:
2026-03-12 15:43:39 -04:00
parent 40c447bd19
commit 93710a1e96
2 changed files with 83 additions and 472 deletions
+81 -8
View File
@@ -12,6 +12,79 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/main.js" defer></script> <script src="/static/main.js" defer></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<script>
let ws = null;
let collectionId = null;
let libraryId = null;
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/sync?token={ user.Token }`;
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log('WebSocket connected');
};
ws.onmessage = function(event) {
try {
const message = JSON.parse(event.data);
if (message.type === 'collection_updated' && message.data.collection_id === collectionId) {
const actionText = message.data.action === 'books_added'
? `Added ${message.data.count || 0} book(s)`
: message.data.action === 'book_removed'
? 'Removed a book'
: message.data.action === 'books_bulk_removed'
? `Removed ${message.data.count || 0} book(s)`
: 'Collection updated';
// Show toast notification
if (window.showToast) {
window.showToast(actionText, 'info');
}
// Check if user is actively typing
const activeElement = document.activeElement;
const isUserActive = activeElement && (
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.tagName === 'SELECT' ||
activeElement.getAttribute('contenteditable') === 'true'
);
if (!isUserActive) {
setTimeout(function() {
location.reload();
}, 1000);
} else {
console.log('User actively typing - skipping auto-reload');
}
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
ws.onclose = function() {
console.log('WebSocket disconnected, reconnecting in 5s...');
setTimeout(connectWebSocket, 5000);
};
ws.onerror = function(error) {
console.error('WebSocket error:', error);
};
}
document.addEventListener('DOMContentLoaded', function() {
// Get collection and library IDs from data attributes
const dataEl = document.getElementById('collection-data');
if (dataEl) {
collectionId = dataEl.dataset.id || '';
libraryId = dataEl.dataset.libraryId || '';
connectWebSocket();
}
});
</script>
</head> </head>
<body x-data="collections" class="theme-{ user.Theme }"> <body x-data="collections" class="theme-{ user.Theme }">
@Header(user, "/collections") @Header(user, "/collections")
@@ -60,7 +133,7 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
</div> </div>
} }
for _, col := range collections { for _, col := range collections {
<div @click="$store.collections.navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block"> <div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
<div <div
class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow" class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary);" style="background-color: var(--bg-secondary);"
@@ -116,7 +189,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
@Header(user, "/collections") @Header(user, "/collections")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6"> <div class="mb-6">
<button @click="$store.collections.backToCollections" class="btn-secondary px-4 py-2 rounded-lg mb-4"> <button @click="backToCollections" class="btn-secondary px-4 py-2 rounded-lg mb-4">
Back to Collections Back to Collections
</button> </button>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@@ -147,13 +220,13 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
</div> </div>
<button <button
id="bulk-remove-btn" id="bulk-remove-btn"
@click="$store.collections.removebooksToAdd" @click="removebooksToAdd"
disabled disabled
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
> >
🗑️ Remove Selected 🗑️ Remove Selected
</button> </button>
<button @click="$store.collections.showAddBooksModal" class="btn-primary px-4 py-2 rounded-lg"> <button @click="showAddBooksModal" class="btn-primary px-4 py-2 rounded-lg">
Add Books Add Books
</button> </button>
</div> </div>
@@ -213,7 +286,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
<!-- Remove Button --> <!-- Remove Button -->
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);"> <div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button <button
@click="$store.collections.removeBook('{ book.MediaItemID }')" @click="removeBook('{ book.MediaItemID }')"
class="px-3 py-1 text-sm border rounded hover:opacity-80" class="px-3 py-1 text-sm border rounded hover:opacity-80"
style="border-color: var(--border); color: var(--text-secondary);" style="border-color: var(--border); color: var(--text-secondary);"
> >
@@ -228,7 +301,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
<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="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"> <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> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Books to Collection</h2>
<button @click="$store.collections.hideAddBooksModal" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button> <button @click="hideAddBooksModal" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div> </div>
<p class="mb-4" style="color: var(--text-secondary)">Search and select books to add to this collection.</p> <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 --> <!-- Library filter toggle - hidden by default, shown by JS when library_id present -->
@@ -249,10 +322,10 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
</div> </div>
<div id="book-results" class="max-h-64 overflow-y-auto mb-4"></div> <div id="book-results" class="max-h-64 overflow-y-auto mb-4"></div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" @click="$store.collections.hideAddBooksModal" class="btn-secondary px-4 py-2 rounded-lg"> <button type="button" @click="hideAddBooksModal" class="btn-secondary px-4 py-2 rounded-lg">
Cancel Cancel
</button> </button>
<button type="button" @click="$store.collections.addbooksToAdd" class="btn-primary px-4 py-2 rounded-lg"> <button type="button" @click="addbooksToAdd" class="btn-primary px-4 py-2 rounded-lg">
Add Selected Books Add Selected Books
</button> </button>
</div> </div>
+2 -464
View File
@@ -421,468 +421,6 @@ function filterIcons(searchTerm: string): void {
(btn as HTMLElement).style.display = matches ? "" : "none"; (btn as HTMLElement).style.display = matches ? "" : "none";
}); });
} }
function showAllIcons(): void {
const iconGrid = document.getElementById("icon-grid");
if (!iconGrid) return;
const buttons = iconGrid.querySelectorAll(".icon-btn");
buttons.forEach((btn) => {
(btn as HTMLElement).style.display = "";
});
}
function initIconSelection(): void {
// First, populate the grid with all icons
populateIconGrid();
// Then, set the current selection
const iconInput = document.getElementById(
"collection-icon",
) as HTMLInputElement;
const searchInput = document.getElementById(
"icon-search",
) as HTMLInputElement;
if (iconInput && iconInput.value && searchInput) {
searchInput.value = iconInput.value;
selectIcon(iconInput.value);
}
}
// ============================================================================
// Collection Detail Page - TypeScript with WebSocket Support
// ============================================================================
interface SearchBookResult {
media_item_id: string;
title: string;
author: string | null;
cover_image_path: string | null;
library_id: string;
library_name: string;
}
interface CollectionUpdateMessage {
type: string;
data: {
collection_id: string;
action: string;
count?: number;
book_id?: string;
};
}
let collectionId = "";
let libraryId = "";
let booksToAdd = new Set<string>();
let booksToRemove = new Set<string>();
let ws: WebSocket | null = null;
// Initialize from data attributes (called on page load)
function initCollectionDetail(): void {
const dataEl = document.getElementById("collection-data");
if (dataEl) {
collectionId = dataEl.dataset.id || "";
libraryId = dataEl.dataset.libraryId || "";
// Show/hide library filter toggle based on whether library_id is present
const filterContainer = document.getElementById("library-filter-container");
if (filterContainer) {
if (libraryId) {
filterContainer.classList.remove("hidden");
// Default: checked (filter by library)
const checkbox = document.getElementById(
"filter-by-library",
) as HTMLInputElement;
if (checkbox) checkbox.checked = true;
} else {
filterContainer.classList.add("hidden");
}
}
}
// Initialize WebSocket connection
connectWebSocket();
}
// Get current library filter setting
function getLibraryFilterParam(): string {
if (!libraryId) return "";
const checkbox = document.getElementById(
"filter-by-library",
) as HTMLInputElement;
if (checkbox && checkbox.checked) {
return `&library_id=${libraryId}`;
}
return "";
}
// WebSocket connection for real-time collection updates
function connectWebSocket(): void {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const token = localStorage.getItem("token");
if (!token) return;
const wsUrl = `${protocol}//${window.location.host}/ws/sync?token=${token}`;
ws = new WebSocket(wsUrl);
ws.onopen = (): void => {
console.log("WebSocket connected");
};
ws.onmessage = (event: MessageEvent): void => {
try {
const message = JSON.parse(event.data) as CollectionUpdateMessage;
if (
message.type === "collection_updated" &&
message.data.collection_id === collectionId
) {
const actionText =
message.data.action === "books_added"
? `Added ${message.data.count || 0} book(s)`
: message.data.action === "book_removed"
? "Removed a book"
: message.data.action === "books_bulk_removed"
? `Removed ${message.data.count || 0} book(s)`
: "Collection updated";
showToast(actionText, "info");
// Mitigation: Skip auto-reload if user is actively typing or interacting
const activeElement = document.activeElement;
const isUserActive =
activeElement &&
(activeElement.tagName === "INPUT" ||
activeElement.tagName === "TEXTAREA" ||
activeElement.tagName === "SELECT" ||
activeElement.getAttribute("contenteditable") === "true");
if (!isUserActive) {
// Auto-reload after 1 second to see updates (only if user not actively typing)
setTimeout(() => {
location.reload();
}, 1000);
} else {
// User is active - just show toast, don't reload
// They'll see updates when they navigate away or manually refresh
console.log("User actively typing - skipping auto-reload");
}
}
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
};
ws.onclose = (): void => {
console.log("WebSocket disconnected, reconnecting in 5s...");
setTimeout(connectWebSocket, 5000);
};
ws.onerror = (error: Event): void => {
console.error("WebSocket error:", error);
};
}
function backToCollections(): void {
window.location.href = "/collections";
}
// Modal functions (called from onclick attributes)
function showAddBooksModal(): void {
const modal = document.getElementById("add-books-modal");
if (modal) modal.classList.remove("hidden");
booksToAdd.clear();
const results = document.getElementById("book-results");
if (results) {
results.innerHTML =
'<p class="text-sm" style="color: var(--text-secondary)">Enter at least 2 characters to search.</p>';
}
}
function hideAddBooksModal(): void {
const modal = document.getElementById("add-books-modal");
if (modal) modal.classList.add("hidden");
const searchInput = document.getElementById(
"book-search",
) as HTMLInputElement;
if (searchInput) searchInput.value = "";
const results = document.getElementById("book-results");
if (results) results.innerHTML = "";
booksToAdd.clear();
}
// Search books - includes library filter
async function searchBooksForCollections(): Promise<void> {
const searchInput = document.getElementById(
"book-search",
) as HTMLInputElement;
const container = document.getElementById("book-results");
if (!searchInput || !container) return;
const searchTerm = searchInput.value;
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>';
const libraryFilter = getLibraryFilterParam();
const token = localStorage.getItem("token");
if (!token) {
container.innerHTML =
'<p class="text-sm" style="color: var(--error)">Authentication required</p>';
return;
}
try {
const response = await fetch(
`/api/media-items/search?q=${encodeURIComponent(searchTerm)}${libraryFilter}`,
{
headers: { Authorization: `Bearer ${token}` },
},
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = (await response.json()) as SearchBookResult[];
if (result.length > 0) {
let html = '<div class="space-y-2">';
result.slice(0, 50).forEach((book) => {
const isSelected = booksToAdd.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>`
: "";
const libraryBadge =
book.library_id === libraryId
? '<span class="text-xs px-1 bg-blue-500 text-white rounded">This Library</span>'
: "";
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>
${libraryBadge}
</div>
`;
});
html += "</div>";
container.innerHTML = html;
} else {
container.innerHTML =
'<p class="text-sm" style="color: var(--text-secondary)">No books found</p>';
}
} catch (error) {
console.error("Search error:", error);
container.innerHTML =
'<p class="text-sm" style="color: var(--error)">Failed to search books</p>';
}
}
// Toggle book selection
function toggleBookSelection(bookId: string): void {
if (booksToAdd.has(bookId)) {
booksToAdd.delete(bookId);
} else {
booksToAdd.add(bookId);
}
searchBooksForCollections();
}
// Add selected books to collection
async function addbooksToAdd(): Promise<void> {
if (booksToAdd.size === 0) {
showToast("Please select at least one book", "error");
return;
}
const bookIds = Array.from(booksToAdd);
const token = localStorage.getItem("token");
if (!token) {
showToast("Authentication required", "error");
return;
}
try {
const response = await fetch(`/api/collections/${collectionId}/books`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ book_ids: bookIds }),
});
if (response.ok) {
showToast(`Added ${bookIds.length} book(s) to collection`, "success");
hideAddBooksModal();
// Note: WebSocket will trigger page reload automatically
} else {
showToast("Failed to add books", "error");
}
} catch (error) {
console.error("Add books error:", error);
showToast("Failed to add books", "error");
}
}
// Remove single book from collection
async function removeBook(bookId: string): Promise<void> {
if (!confirm("Remove this book from the collection?")) return;
const token = localStorage.getItem("token");
if (!token) {
showToast("Authentication required", "error");
return;
}
try {
const response = await fetch(
`/api/collections/${collectionId}/books/${bookId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
},
);
if (response.ok) {
showToast("Book removed from collection", "success");
// Note: WebSocket will trigger page reload automatically
} else {
showToast("Failed to remove book", "error");
}
} catch (error) {
console.error("Remove book error:", error);
showToast("Failed to remove book", "error");
}
}
// Bulk remove functions
function toggleBookForRemoval(bookId: string): void {
if (booksToRemove.has(bookId)) {
booksToRemove.delete(bookId);
} else {
booksToRemove.add(bookId);
}
updateSelectedCount();
}
function updateSelectedCount(): void {
const count = booksToRemove.size;
const countSpan = document.getElementById("selected-count");
const removeBtn = document.getElementById(
"bulk-remove-btn",
) as HTMLButtonElement;
if (count > 0) {
if (countSpan) {
countSpan.textContent = `${count} selected`;
countSpan.classList.remove("hidden");
}
if (removeBtn) removeBtn.disabled = false;
} else {
if (countSpan) countSpan.classList.add("hidden");
if (removeBtn) removeBtn.disabled = true;
}
}
async function removebooksToAdd(): Promise<void> {
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);
const token = localStorage.getItem("token");
if (!token) {
showToast("Authentication required", "error");
return;
}
try {
const response = await fetch(
`/api/collections/${collectionId}/books/bulk-remove`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ book_ids: bookIds }),
},
);
if (response.ok) {
const result = (await response.json()) as { removed: number };
if (result.removed > 0) {
showToast(
`Removed ${result.removed} book(s) from collection`,
"success",
);
// Note: WebSocket will trigger page reload automatically
} else {
showToast("Failed to remove books", "error");
}
} else {
showToast("Failed to remove books", "error");
}
} catch (error) {
console.error("Bulk remove error:", error);
showToast("Failed to remove books", "error");
}
}
// Client-side search filter for displayed books
function filterCollectionBooks(): void {
const searchTerm =
(
document.getElementById("collection-search") as HTMLInputElement
)?.value.toLowerCase() || "";
const booksContainer = document.getElementById("books-container");
if (!booksContainer) return;
const bookCards = booksContainer.children;
for (let i = 0; i < bookCards.length; i++) {
const card = bookCards[i] as HTMLElement;
if (card.id === "empty-state") continue;
const titleEl = card.querySelector(".font-semibold");
const authorEl = card.querySelector(".text-sm");
const title = titleEl?.textContent?.toLowerCase() || "";
const author = authorEl?.textContent?.toLowerCase() || "";
const matches = title.includes(searchTerm) || author.includes(searchTerm);
card.style.display = matches || searchTerm === "" ? "" : "none";
}
}
// Auto-initialize
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initCollectionDetail);
} else {
initCollectionDetail();
}
// Export functions globally // Export functions globally
export { export {
@@ -914,7 +452,7 @@ export {
updateSelectedCount, updateSelectedCount,
}; };
Alpine.store("collections", { Alpine.data("collections", () => ({
addbooksToAdd, addbooksToAdd,
backToCollections, backToCollections,
closeCollectionModal, closeCollectionModal,
@@ -941,4 +479,4 @@ Alpine.store("collections", {
toggleBookForRemoval, toggleBookForRemoval,
toggleBookSelection, toggleBookSelection,
updateSelectedCount, updateSelectedCount,
}); }));