"Newest episodes from my selected podcasts" is now buildable in the UI, end to end — the user could not find ANY podcast-related filter surface because none existed: the New Collection modal could only create book-scope smart collections, and the engine's episodes scope (item 3.1) had no authoring door. The scope door: - The create modal's Smart card gains a Rules-scope segmented control (📚 Books / 🎧 Podcast episodes); the copy no longer claims smart means "match books by rules". Choosing Episodes seeds an EMPTY episodes tree ({"scope":"episodes","mode":"all","children":[]}) so the collection is born episode-scoped: the editor opens with the episode vocabulary (feed/downloaded/played/published date) and membership runs the episode engine from the first rule. - ParseFilterTree now accepts an EMPTY ROOT (nested empty groups stay invalid) — the seed's legal shape. The root arrives at depth 1 by call convention, hence the depth>1 exemption. - The composers define the empty-tree semantics: zero children matches NOTHING (ComposedLiveMedia/ComposedCount guards), mirroring EvaluateSmartCollection's zero-enabled-rules convention. Without the guard the composed query degrades to the bare scope head and a fresh collection would swallow every episode in scope — pinned by TestEpisodeCollectionsEmptySeedMatchesNothing (envelope book_count 0) and TestEmptyRootTreeSeedsEpisodesScope. The curated chain order (plan §10 R2, user need: the briefing plays NEWEST-first): - view_settings.chain_order ("native"/"newest"/"oldest"/"reverse") stored on the collection — curated content, not a player-side knob. - PUT /api/collections/:id/view-settings merges the key into the stored JSONB (a blind write would clobber the playlist toggles). - buildSmartCollectionSequenceContext routes OrderEpisodes through handlers.CollectionChainOrder; the detail header's Play-as-Sequence gains the order select next to it (PlayCollectionButton), which PUTs and reloads. Pinned by chain_follows_the_curated_order (reverse flips the chain indexes and hops). Caught by the new test, a latent clobber fixed: UpdateCollection treated a NIL view_settings payload as "write NULL" — any edit that carried no settings dropped every stored key (chain_order, playlist toggles from API clients, imported provenance). Nil now means untouched, the same convention filterTree already had. Composer guards also cover ComposedCount, so the builder's live count reads the same semantics. Tests: collectionChainOrder unit matrix, empty-root parse unit pin, two integration pins (seed matches nothing; curated order drives the chain and survives whole-map edits).
519 lines
17 KiB
TypeScript
519 lines
17 KiB
TypeScript
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 <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(): 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<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> {
|
|
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<void> {
|
|
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<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.
|
|
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<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));
|
|
|
|
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");
|
|
}
|
|
}
|