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 renderCollectionBooks(books: BookInfo[]): void {
const container = document.getElementById("books-container");
if (!container) return;
const dataEl = document.getElementById("collection-data");
const isSystem = dataEl?.dataset.isSystem === "true";
// Smart collections are rule-managed: no hand-pick actions either way.
const canEditMembership =
!isSystem && dataEl?.dataset.isSmart !== "true";
if (books.length === 0) {
container.innerHTML = `
No books in this collection yet.
`;
return;
}
container.innerHTML = books
.map(
(book) => `
${canEditMembership ? `
` : ""}
${canEditMembership ? `
` : ""}
`,
)
.join("");
Alpine.initTree(container);
}
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 () => {
const param = libraryId ? `?library_id=${libraryId}` : "";
const response = await fetch(
`/api/collections/${collId}${param}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
},
);
if (!response.ok) throw new Error("Failed to load collection");
const data = await response.json();
renderCollectionBooks(data.books || []);
});
},
});
}
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,
};
Alpine.data("collections", () => ({
closeCollectionModal,
createRule,
deleteRule,
filterCollectionBooks,
filterIcons,
initColorSelection,
initializeCollectionWebSocket,
initCollectionsPage,
loadCollectionRules,
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;
},
}));