Files
bookhoard/web/src/collections.ts
T
John O'Keefe cc0e9d7f91 feat(podcasts): the episodes authoring door + curated chain order
"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).
2026-10-31 13:39:24 -04:00

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");
}
}