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
+2 -464
View File
@@ -421,468 +421,6 @@ function filterIcons(searchTerm: string): void {
(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 {
@@ -914,7 +452,7 @@ export {
updateSelectedCount,
};
Alpine.store("collections", {
Alpine.data("collections", () => ({
addbooksToAdd,
backToCollections,
closeCollectionModal,
@@ -941,4 +479,4 @@ Alpine.store("collections", {
toggleBookForRemoval,
toggleBookSelection,
updateSelectedCount,
});
}));