diff --git a/templates/collections.templ b/templates/collections.templ index dccbd6b..ae67fdb 100644 --- a/templates/collections.templ +++ b/templates/collections.templ @@ -12,6 +12,79 @@ templ Collection(user User, collections []CollectionData, errorMessage string) { + @Header(user, "/collections") @@ -60,7 +133,7 @@ templ Collection(user User, collections []CollectionData, errorMessage string) { } for _, col := range collections { -
+
-
@@ -147,13 +220,13 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
-
@@ -213,7 +286,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
+

Search and select books to add to this collection.

@@ -249,10 +322,10 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
- -
diff --git a/web/src/collections.ts b/web/src/collections.ts index b41bc4e..68ea040 100644 --- a/web/src/collections.ts +++ b/web/src/collections.ts @@ -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(); -let booksToRemove = new Set(); -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 = - '

Enter at least 2 characters to search.

'; - } -} - -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 { - 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 = - '

Enter at least 2 characters to search.

'; - return; - } - - container.innerHTML = - '

Searching...

'; - - const libraryFilter = getLibraryFilterParam(); - const token = localStorage.getItem("token"); - if (!token) { - container.innerHTML = - '

Authentication required

'; - 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 = '
'; - result.slice(0, 50).forEach((book) => { - const isSelected = booksToAdd.has(book.media_item_id); - const checkedAttr = isSelected ? "checked" : ""; - const authorHtml = book.author - ? `
${book.author}
` - : ""; - const libraryBadge = - book.library_id === libraryId - ? 'This Library' - : ""; - - html += ` -
- - Cover -
-
${book.title}
- ${authorHtml} -
- ${libraryBadge} -
- `; - }); - html += "
"; - container.innerHTML = html; - } else { - container.innerHTML = - '

No books found

'; - } - } catch (error) { - console.error("Search error:", error); - container.innerHTML = - '

Failed to search books

'; - } -} - -// 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 { - 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 { - 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 { - 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, -}); +}));