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, }); } 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, event?: Event): void { // ANY ancestor-or-self button swallows the click. The guard used to be // `event.target instanceof HTMLElement` โ€” false for SVG elements โ€” so // a click landing exactly on the pencil glyph's / sailed // past it and navigated while the htmx edit-modal request still fired // (user report 2026-10-31: modal "flashes", then the builder page). // Element.closest works on SVGElement too; window.event is also gone // (deprecated) in favor of the passed $event. const target = event?.target as Element | null; if (target && typeof target.closest === "function" && 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, filterCollectionBooks, filterIcons, initColorSelection, initializeCollectionWebSocket, initCollectionsPage, navigateToCollection, populateIconGrid, selectColor, selectIcon, setupHTMXAuth, setupHTMXModalInit, showAllIcons, toggleSelection, moveCollectionBook, }; Alpine.data("collections", () => ({ closeCollectionModal, filterCollectionBooks, filterIcons, initColorSelection, initializeCollectionWebSocket, initCollectionsPage, moveCollectionBook, navigateToCollection, populateIconGrid, selectColor, selectIcon, selectedBooks: [] as string[], setupHTMXModalInit, showAllIcons, toggleSelection, // โ”€โ”€ create-modal: type + smart scope (the episodes scope door) โ”€โ”€ // Static/Smart radio state and, for smart collections, which item // kind the rules match. The modal copy no longer claims smart means // "books": episodes-scope smart collections are first-class. collectionType: "static" as string, smartScope: "books" as string, setCollectionType(this: any, t: string) { this.collectionType = t === "smart" ? "smart" : "static"; this.syncTreeSeed(); }, setSmartScope(this: any, s: string) { this.smartScope = s === "episodes" ? "episodes" : "books"; this.syncTreeSeed(); }, // syncTreeSeed โ€” the create form's hidden filter_tree input. An // Episodes-scope smart collection seeds an EMPTY episodes tree so the // editor opens with the episode vocabulary and membership runs the // episode engine from birth; Books stays tree-less (the editor's // books default). syncTreeSeed(this: any) { const seed = this.collectionType === "smart" && this.smartScope === "episodes" ? '{"scope":"episodes","mode":"all","children":[]}' : ""; const input = document.getElementById("collection-tree-seed") as HTMLInputElement | null; if (input) input.value = seed; }, showConfirm: false, confirmMessage: "", confirmLabel: "Remove", pendingAction: null as null | (() => void), // setChainOrder โ€” the detail header's listening-order select (plan // ยง10 R2: the collection's chain order is curated HERE, not on the // player). Merges into view_settings server-side, then reloads so // the chain producer's stored order takes effect. setChainOrder(this: any, collectionID: string, order: string) { const token = localStorage.getItem("token"); if (!token) return; void fetch("/api/collections/" + collectionID + "/view-settings", { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ chain_order: order }), }).then((r) => { if (r.ok) { window.location.reload(); } else { void import("./toast-error").then((m) => m.showErrorToast("Could not save the listening order")); } }); }, 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"); } }