import { Alpine } from "./alpine"; import { showToast } from "./toast"; import { createWebSocket } from "./websocket"; import { initLibrarySwitcher, switchWithTransition } from "./library-switcher"; let collectionId: string | null = null; function initializeCollectionWebSocket(): void { const dataEl = document.getElementById("collection-data"); if (!dataEl) return; collectionId = dataEl.dataset.id || null; if (!collectionId) return; createWebSocket({ onMessage: (message) => { 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"); const activeElement = document.activeElement; const isUserActive = activeElement && (activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.tagName === "SELECT" || activeElement.getAttribute("contenteditable") === "true"); if (!isUserActive) { setTimeout(() => { location.reload(); }, 1000); } } }, enableReconnect: true, reconnectDelay: 5000, }); } async function loadCollectionRules(collectionId: string): Promise { const token = localStorage.getItem("token"); if (!token) return; try { const response = await fetch(`/api/collections/${collectionId}/rules`, { headers: { Authorization: `Bearer ${token}` }, }); if (response.ok) { const rules: CollectionRule[] = await response.json(); renderRules(rules); } } catch (error) { console.error("Failed to load rules:", error); } } function renderRules(rules: CollectionRule[]): void { const container = document.getElementById("rules-list"); if (!container) return; if (rules.length === 0) { container.innerHTML = '

No rules defined

'; return; } container.innerHTML = rules .map( (rule) => `

${rule.field} ${rule.operator} "${rule.value}"

Priority: ${rule.priority} | ${rule.enabled ? "Enabled" : "Disabled"}

`, ) .join(""); } async function createRule(collectionId: string, rule: Partial): Promise { const token = localStorage.getItem("token"); if (!token) return; try { const response = await fetch(`/api/collections/${collectionId}/rules`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify(rule), }); if (response.ok) { showToast("Rule created", "success"); loadCollectionRules(collectionId); } else { const error = await response.json(); showToast(error.error || "Failed to create rule", "error"); } } catch (error) { console.error("Failed to create rule:", error); showToast("Failed to create rule", "error"); } } async function deleteRule(collectionId: string, ruleId: string): Promise { const token = localStorage.getItem("token"); if (!token) return; if (!confirm("Are you sure you want to delete this rule?")) return; try { const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }); if (response.ok) { showToast("Rule deleted", "success"); loadCollectionRules(collectionId); } } catch (error) { console.error("Failed to delete rule:", error); showToast("Failed to delete rule", "error"); } } async function testRule(collectionId: string, rule: Partial): Promise { const token = localStorage.getItem("token"); if (!token) return; try { const response = await fetch(`/api/collections/${collectionId}/rules/test`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify(rule), }); if (response.ok) { const results = await response.json(); renderTestResults(results); } } catch (error) { console.error("Failed to test rule:", error); showToast("Failed to test rule", "error"); } } function renderTestResults(results: unknown[]): void { const container = document.getElementById("test-results"); if (!container) return; if (!results || (Array.isArray(results) && results.length === 0)) { container.innerHTML = '

No matching books found

'; return; } container.innerHTML = `

${Array.isArray(results) ? results.length : 0} matching books

`; } function setupHTMXAuth(): void { document.body.addEventListener("htmx:configRequest", function (evt: Event) { const token = localStorage.getItem("token"); (evt as any).detail.headers["Authorization"] = `Bearer ${token}`; }); } function navigateToCollection(element: HTMLElement): void { const event = window.event as Event; if (event && event.target instanceof HTMLElement) { const target = event.target as HTMLElement; if (target.tagName === "BUTTON" || target.closest("button") !== null) { return; } } const href = element.getAttribute("data-href"); if (href) { window.location.href = href; } } const borderClasses: Record = { blue: "border-blue-500", red: "border-red-500", yellow: "border-yellow-500", green: "border-green-500", purple: "border-purple-500", }; function selectColor(color: string): void { const colorInput = document.getElementById("collection-color") as HTMLInputElement; if (colorInput) { colorInput.value = color; } document.querySelectorAll(".color-option").forEach((btn) => { (btn as HTMLElement).style.outline = "none"; (btn as HTMLElement).style.outlineOffset = "0"; }); const selectedBtn = document.querySelector(`.color-option[data-color="${color}"]`) as HTMLElement; if (selectedBtn) { selectedBtn.style.outline = "3px solid var(--text-primary)"; selectedBtn.style.outlineOffset = "3px"; } } function closeCollectionModal(): void { const modal = document.querySelector(".fixed.inset-0"); if (modal) { modal.remove(); } } function initColorSelection(): void { const colorInput = document.getElementById("collection-color") as HTMLInputElement; if (colorInput && colorInput.value) { selectColor(colorInput.value); } document.querySelectorAll("[data-color]").forEach((card) => { const color = (card as HTMLElement).getAttribute("data-color"); if (color && borderClasses[color]) { Object.values(borderClasses).forEach((cls) => { (card as HTMLElement).classList.remove(cls); }); (card as HTMLElement).classList.add(borderClasses[color]); } }); } function setupHTMXModalInit(): void { document.body.addEventListener("htmx:afterSwap", function (evt: CustomEvent) { const target = evt.detail.target; if (target && target.id === "modal-container") { populateIconGrid(); initColorSelection(); Alpine.initTree(target); } if (target && target.id === "book-picker-grid") { Alpine.initTree(target); } }); } function showAllIcons(): void { populateIconGrid(); const searchInput = document.getElementById("icon-search") as HTMLInputElement; if (searchInput) { searchInput.value = ""; } const iconGrid = document.getElementById("icon-grid"); if (!iconGrid) return; const buttons = iconGrid.querySelectorAll(".icon-btn"); buttons.forEach((btn) => { (btn as HTMLElement).style.display = ""; }); } const iconData: Record = { "๐Ÿ“š": ["book", "books", "library", "read", "reading"], "๐Ÿ“–": ["book", "open", "read", "reading"], "๐Ÿ“": ["memo", "note", "write", "writing", "edit"], "๐Ÿ“ฐ": ["news", "newspaper", "press", "article"], "๐Ÿ—‚๏ธ": ["card", "index", "organize", "file"], "๐Ÿ“": ["file", "folder", "directory"], "๐Ÿ“‚": ["folder", "open", "directory"], "๐Ÿ““": ["notebook", "book", "write"], "๐Ÿ“”": ["notebook", "book"], "๐Ÿ“•": ["book", "read", "red"], "๐Ÿ“—": ["book", "read", "green"], "๐Ÿ“˜": ["book", "read", "blue"], "๐Ÿ“™": ["book", "read", "orange"], "โญ": ["star", "favorite", "like", "rating"], "โค๏ธ": ["heart", "love", "favorite", "like"], "๐Ÿ”ฅ": ["fire", "hot", "popular", "trending", "flame"], "๐Ÿ’ก": ["idea", "bulb", "light", "think", "smart"], "๐ŸŽฏ": ["target", "goal", "aim", "focus"], "๐Ÿ†": ["trophy", "winner", "award", "achievement"], "๐ŸŽจ": ["art", "paint", "color", "creative"], "โœ…": ["check", "done", "complete", "success"], "โŒ": ["cross", "x", "wrong", "error", "fail"], "โšก๏ธ": ["bolt", "fast", "quick", "energy"], "๐Ÿš€": ["rocket", "fast", "launch", "space"], "๐Ÿ’Ž": ["gem", "diamond", "stone", "rich"], "๐Ÿ‘": ["thumb", "up", "good", "yes", "like"], "๐Ÿ‘Ž": ["thumb", "down", "bad", "no", "dislike"], "๐Ÿ ": ["house", "home", "building"], "๐Ÿข": ["building", "office", "work", "company"], "๐Ÿš—": ["car", "auto", "vehicle", "drive"], "โœˆ๏ธ": ["plane", "airplane", "fly", "travel"], "๐ŸŽฎ": ["game", "play", "video", "gaming"], }; function populateIconGrid(): void { const iconGrid = document.getElementById("icon-grid"); if (!iconGrid) return; iconGrid.innerHTML = ""; Object.entries(iconData).forEach(([emoji, keywords]) => { const button = document.createElement("button"); button.type = "button"; button.className = "icon-btn text-2xl p-1 hover:bg-opacity-80 rounded"; button.textContent = emoji; button.setAttribute("data-emoji", emoji); button.setAttribute("data-keywords", keywords.join(",")); button.addEventListener("click", () => selectIcon(emoji)); iconGrid.appendChild(button); }); } function selectIcon(icon: string): void { const iconInput = document.getElementById("collection-icon") as HTMLInputElement; const searchInput = document.getElementById("icon-search") as HTMLInputElement; if (iconInput) iconInput.value = icon; if (searchInput) searchInput.value = icon; document.querySelectorAll(".icon-btn").forEach((btn) => { (btn as HTMLElement).style.outline = "none"; (btn as HTMLElement).style.backgroundColor = ""; }); const selectedBtn = document.querySelector(`.icon-btn[data-emoji="${icon}"]`) as HTMLElement; if (selectedBtn) { selectedBtn.style.outline = "2px solid var(--text-primary)"; selectedBtn.style.backgroundColor = "var(--bg-secondary)"; } } function filterIcons(searchTerm: string): void { const iconGrid = document.getElementById("icon-grid"); const iconInput = document.getElementById("collection-icon") as HTMLInputElement; if (iconInput) iconInput.value = searchTerm; if (!iconGrid) return; const buttons = iconGrid.querySelectorAll(".icon-btn"); const lowerSearchTerm = searchTerm.toLowerCase(); buttons.forEach((btn) => { const emoji = (btn as HTMLElement).textContent || ""; const keywords = iconData[emoji] || []; const matches = searchTerm === "" || emoji.includes(searchTerm) || keywords.some((keyword) => keyword.toLowerCase().includes(lowerSearchTerm)); (btn as HTMLElement).style.display = matches ? "" : "none"; }); } function isDetailPage(): boolean { const dataEl = document.getElementById("collection-data"); return !!dataEl?.dataset.id; } function initCollectionsPage(): void { if (isDetailPage()) { const dataEl = document.getElementById("collection-data"); const collId = dataEl?.dataset.id || ""; initLibrarySwitcher({ onSwitch: async (libraryId) => { const dataEl = document.getElementById("collection-data"); if (dataEl) dataEl.dataset.libraryId = libraryId; await switchWithTransition("books-container", async () => { // Rendering ownership: the grid is server-rendered markup. Swap // in the templ partial instead of re-rendering from JSON. const param = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : ""; await window.htmx.ajax("GET", `/collections/${collId}/books-grid${param}`, { target: "#books-container", swap: "innerHTML", }); const container = document.getElementById("books-container"); if (container) Alpine.initTree(container); }); }, }); } initializeCollectionWebSocket(); setupHTMXModalInit(); } function getCollectionId(): string { const dataEl = document.getElementById("collection-data"); return dataEl?.dataset.id || ""; } function toggleSelection(this: any, id: string): void { const idx = this.selectedBooks.indexOf(id); if (idx >= 0) { this.selectedBooks.splice(idx, 1); } else { this.selectedBooks.push(id); } } async function doRemoveBook(id: string): Promise { const token = localStorage.getItem("token"); if (!token) return; try { const response = await fetch(`/api/collections/${getCollectionId()}/books/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }); if (response.ok) { showToast("Removed from collection", "success"); setTimeout(() => location.reload(), 500); } else { showToast("Failed to remove book", "error"); } } catch { showToast("Error removing book", "error"); } } async function doBulkRemove(ids: string[]): Promise { const token = localStorage.getItem("token"); if (!token) return; try { const response = await fetch(`/api/collections/${getCollectionId()}/books/bulk-remove`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ book_ids: ids }), }); if (response.ok) { showToast(`Removed ${ids.length} book(s)`, "success"); setTimeout(() => location.reload(), 500); } else { showToast("Failed to remove books", "error"); } } catch { showToast("Error removing books", "error"); } } function filterCollectionBooks(): void { const input = document.getElementById("collection-search") as HTMLInputElement; if (!input) return; const query = input.value.toLowerCase(); const cards = document.querySelectorAll(".collection-book-card"); cards.forEach((card) => { const title = (card.dataset.title || "").toLowerCase(); const author = (card.dataset.author || "").toLowerCase(); card.style.display = title.includes(query) || author.includes(query) ? "" : "none"; }); } export { closeCollectionModal, createRule, deleteRule, filterCollectionBooks, filterIcons, initColorSelection, initializeCollectionWebSocket, initCollectionsPage, loadCollectionRules, navigateToCollection, populateIconGrid, selectColor, selectIcon, setupHTMXAuth, setupHTMXModalInit, showAllIcons, testRule, toggleSelection, moveCollectionBook, }; Alpine.data("collections", () => ({ closeCollectionModal, createRule, deleteRule, filterCollectionBooks, filterIcons, initColorSelection, initializeCollectionWebSocket, initCollectionsPage, loadCollectionRules, moveCollectionBook, navigateToCollection, populateIconGrid, selectColor, selectIcon, selectedBooks: [] as string[], setupHTMXModalInit, showAllIcons, testRule, toggleSelection, showConfirm: false, confirmMessage: "", confirmLabel: "Remove", pendingAction: null as null | (() => void), requestRemoveBook(this: any, id: string) { this.confirmMessage = "Remove this book from the collection?"; this.pendingAction = () => doRemoveBook(id); this.showConfirm = true; }, requestBulkRemove(this: any) { if (this.selectedBooks.length === 0) return; this.confirmMessage = `Remove ${this.selectedBooks.length} book(s) from this collection?`; const ids = [...this.selectedBooks]; this.pendingAction = () => doBulkRemove(ids); this.showConfirm = true; }, executeConfirmed(this: any) { this.showConfirm = false; const action = this.pendingAction; this.pendingAction = null; if (action) action(); }, closeConfirm(this: any) { this.showConfirm = false; this.pendingAction = null; }, })); // A2c ยง3.2: playlist ordering โ€” move a card one slot and persist the full // resulting order. The DOM cards are the server-rendered truth for CURRENT // order; the API is the truth for SAVED order. On failure the grid refresh // from the templ partial so the UI never lies. async function moveCollectionBook(this: any, mediaId: string, direction: number): Promise { const container = document.getElementById("books-container"); if (!container) return; const cards = Array.from(container.querySelectorAll(".collection-book-card")); const index = cards.findIndex((c) => c.dataset.mediaId === mediaId); const target = index + direction; if (index < 0 || target < 0 || target >= cards.length) return; // Optimistic DOM swap (client-local state until the API confirms). if (direction < 0) { container.insertBefore(cards[index], cards[target]); } else { container.insertBefore(cards[target], cards[index]); } const orderedIds = Array.from(container.querySelectorAll(".collection-book-card")) .map((c) => c.dataset.mediaId) .filter((id): id is string => Boolean(id)); const token = localStorage.getItem("token"); if (!token) return; try { const response = await fetch(`/api/collections/${getCollectionId()}/items/order`, { method: "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ item_ids: orderedIds }), }); if (!response.ok) { showToast("Failed to save the new order", "error"); // Rendering ownership: the templ partial re-renders the true order. const gridTarget = document.getElementById("books-container"); if (gridTarget && window.htmx) { window.htmx.ajax( "GET", `/collections/${getCollectionId()}/books-grid${window.location.search.startsWith("?") ? window.location.search : ""}`, { target: "#books-container", swap: "innerHTML" }, ); } } } catch { showToast("Failed to save the new order", "error"); } }