Pre-existing format drift in seven web/src files (blank-line and indent normalization by vp fmt) plus templ generate normalization across the generated _templ.go files; both swept up while committing the filter-builder fix. No behavior changes.
520 lines
18 KiB
TypeScript
520 lines
18 KiB
TypeScript
import { Alpine } from "./alpine";
|
|
import { authHeaders } from "./storage";
|
|
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");
|
|
// Only attach a bearer when localStorage has one — htmx sends
|
|
// cookies regardless, and "Bearer null" is never needed.
|
|
if (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 <svg>/<path> 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<string, string> = {
|
|
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(from?: HTMLElement): void {
|
|
// The overlay OWNING the caller's button — never `.fixed.inset-0`:
|
|
// that bare selector matched the SITEWIDE confirm modal first (it
|
|
// renders in the header, before any modal container), so Close
|
|
// removed a hidden dialog and left the real modal open — Close and
|
|
// Cancel did nothing (knob-matrix spec, 2026-10-31).
|
|
const modal =
|
|
from?.closest(".fixed.inset-0") ?? document.querySelector("#modal-container > .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<string, string[]> = {
|
|
"📚": ["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<void> {
|
|
try {
|
|
const response = await fetch(`/api/collections/${getCollectionId()}/books/${id}`, {
|
|
method: "DELETE",
|
|
headers: authHeaders(),
|
|
});
|
|
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<void> {
|
|
try {
|
|
const response = await fetch(`/api/collections/${getCollectionId()}/books/bulk-remove`, {
|
|
method: "POST",
|
|
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
|
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<HTMLElement>(".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. The save rides
|
|
// the token COOKIE (middleware cookie:token lookup) — never gate on
|
|
// localStorage: the 401 interceptor clears it, and a token-gated
|
|
// early-return would strand the setting (same class as the
|
|
// changeTheme fix, 2026-10-31).
|
|
setChainOrder(this: any, collectionID: string, order: string) {
|
|
const token = localStorage.getItem("token");
|
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
if (token) headers.Authorization = "Bearer " + token;
|
|
void fetch("/api/collections/" + collectionID + "/view-settings", {
|
|
method: "PUT",
|
|
headers,
|
|
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<void> {
|
|
const container = document.getElementById("books-container");
|
|
if (!container) return;
|
|
const cards = Array.from(container.querySelectorAll<HTMLElement>(".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<HTMLElement>(".collection-book-card"))
|
|
.map((c) => c.dataset.mediaId)
|
|
.filter((id): id is string => Boolean(id));
|
|
|
|
try {
|
|
const response = await fetch(`/api/collections/${getCollectionId()}/items/order`, {
|
|
method: "PUT",
|
|
headers: { ...authHeaders(), "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");
|
|
}
|
|
}
|