- Remove DOMContentLoaded listeners for setupHTMXAuth, initColorSelection, and setupHTMXModalInit
- Delete dead Alpine.data exports: addbooksToAdd, removebooksToAdd, toggleBookForRemoval,
toggleBookSelection, initCollectionDetail, initIconSelection, initColorSelection
- Add missing setupHTMXAuth to export statement (it was called but not exported)
- Remove 14 lines of auto-initialization code that's no longer needed
This fixes "X is not defined" console errors for functions that were deleted
in commit 93710a1 but were still in Alpine.data export. The collections.templ template
was also updated to remove calls to these deleted functions.
These changes align with the SSR architecture where most collection functionality
is server-rendered and client-side JavaScript is used sparingly.
465 lines
14 KiB
TypeScript
465 lines
14 KiB
TypeScript
import { Alpine } from "./alpine";
|
|
import { showToast } from "./toast";
|
|
|
|
async function loadCollections(): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
try {
|
|
const response = await fetch("/api/collections", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
renderCollections(data.collections || []);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load collections:", error);
|
|
}
|
|
}
|
|
|
|
function renderCollections(collections: CollectionData[]): void {
|
|
const container = document.getElementById("collections-list");
|
|
if (!container) return;
|
|
|
|
if (collections.length === 0) {
|
|
container.innerHTML =
|
|
'<p class="text-center p-4" style="color: var(--text-secondary)">No collections yet</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = collections
|
|
.map(
|
|
(collection) => `
|
|
<a href="/collections/${collection.id}" class="block p-4 rounded-lg border transition-colors hover:border-opacity-50"
|
|
style="background-color: var(--bg-secondary); border-color: var(--border)">
|
|
<div class="flex items-center space-x-3">
|
|
<span class="text-2xl">${collection.icon || "📁"}</span>
|
|
<div>
|
|
<h3 class="font-medium" style="color: var(--text-primary)">${collection.name}</h3>
|
|
${collection.description ? `<p class="text-sm" style="color: var(--text-secondary)">${collection.description}</p>` : ""}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
`,
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
async function loadCollectionRules(collectionId: string): Promise<void> {
|
|
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 =
|
|
'<p class="text-center p-4" style="color: var(--text-secondary)">No rules defined</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = rules
|
|
.map(
|
|
(rule) => `
|
|
<div class="p-3 rounded-lg border mb-2 flex justify-between items-center"
|
|
style="background-color: var(--bg-secondary); border-color: var(--border)">
|
|
<div>
|
|
<p class="font-medium" style="color: var(--text-primary)">${rule.field} ${rule.operator} "${rule.value}"</p>
|
|
<p class="text-sm" style="color: var(--text-secondary)">Priority: ${rule.priority} | ${rule.enabled ? "Enabled" : "Disabled"}</p>
|
|
</div>
|
|
<div class="flex space-x-2">
|
|
<button onclick="window.editRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Edit</button>
|
|
<button onclick="window.deleteRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
|
|
</div>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
async function createRule(
|
|
collectionId: string,
|
|
rule: Partial<CollectionRule>,
|
|
): Promise<void> {
|
|
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<void> {
|
|
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<CollectionRule>,
|
|
): Promise<void> {
|
|
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 =
|
|
'<p class="p-2 text-sm" style="color: var(--text-secondary)">No matching books found</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = `<p class="p-2 text-sm" style="color: var(--text-secondary)">${Array.isArray(results) ? results.length : 0} matching books</p>`;
|
|
}
|
|
|
|
// Add authorization header to all HTMX requests
|
|
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 {
|
|
// Check if the click target is a button
|
|
const event = window.event as Event;
|
|
if (event && event.target instanceof HTMLElement) {
|
|
const target = event.target as HTMLElement;
|
|
|
|
// If user clicked a button or button icon, don't navigate
|
|
if (target.tagName === "BUTTON" || target.closest("button") !== null) {
|
|
return; // Let HTMX handle the button action
|
|
}
|
|
}
|
|
|
|
// Only navigate if clicking the card body
|
|
const href = element.getAttribute("data-href");
|
|
if (href) {
|
|
window.location.href = href;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Collection Modal UI Helpers
|
|
// ============================================================================
|
|
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",
|
|
};
|
|
|
|
// Color selection for create/edit modal
|
|
function selectColor(color: string): void {
|
|
const colorInput = document.getElementById(
|
|
"collection-color",
|
|
) as HTMLInputElement;
|
|
if (colorInput) {
|
|
colorInput.value = color;
|
|
}
|
|
// Update visual selection
|
|
document.querySelectorAll(".color-option").forEach((btn) => {
|
|
(btn as HTMLElement).style.outline = "none";
|
|
(btn as HTMLElement).style.outlineOffset = "0";
|
|
});
|
|
const selectedBtn = document.querySelector(
|
|
`.color-option[onclick="selectColor('${color}')"]`,
|
|
) as HTMLElement;
|
|
if (selectedBtn) {
|
|
selectedBtn.style.outline = "3px solid var(--text-primary)";
|
|
selectedBtn.style.outlineOffset = "3px";
|
|
}
|
|
}
|
|
// Close modal (removes from DOM)
|
|
function closeCollectionModal(): void {
|
|
const modal = document.querySelector(".fixed.inset-0");
|
|
if (modal && modal.parentElement) {
|
|
modal.parentElement.remove();
|
|
}
|
|
}
|
|
|
|
// Initialize color selection on page load
|
|
function initColorSelection(): void {
|
|
const colorInput = document.getElementById(
|
|
"collection-color",
|
|
) as HTMLInputElement;
|
|
if (colorInput && colorInput.value) {
|
|
selectColor(colorInput.value);
|
|
}
|
|
|
|
// Apply border color classes to collection cards
|
|
document.querySelectorAll("[data-color]").forEach((card) => {
|
|
const color = (card as HTMLElement).getAttribute("data-color");
|
|
if (color && borderClasses[color]) {
|
|
// Remove old border color classes
|
|
Object.values(borderClasses).forEach((cls) => {
|
|
(card as HTMLElement).classList.remove(cls);
|
|
});
|
|
// Add new border color class
|
|
(card as HTMLElement).classList.add(borderClasses[color]);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initialize icon grid when modal is loaded via HTMX
|
|
function setupHTMXModalInit(): void {
|
|
document.body.addEventListener("htmx:afterSwap", function (evt: Event) {
|
|
const target = (evt as any).detail.target;
|
|
if (target && target.id === "modal-container") {
|
|
initIconSelection();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Icon data with keywords (single source of truth)
|
|
const iconData: Record<string, string[]> = {
|
|
// Books & Reading
|
|
"📚": ["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"],
|
|
|
|
// Favorites & Activities
|
|
"⭐": ["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"],
|
|
|
|
// Places & Objects
|
|
"💎": ["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"],
|
|
};
|
|
// Helper: Get just the emoji list
|
|
|
|
function populateIconGrid(): void {
|
|
const iconGrid = document.getElementById("icon-grid");
|
|
if (!iconGrid) return;
|
|
// Clear any existing content
|
|
iconGrid.innerHTML = "";
|
|
// Generate buttons from iconData
|
|
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("onclick", `selectIcon('${emoji}')`);
|
|
button.setAttribute("data-keywords", keywords.join(","));
|
|
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;
|
|
|
|
// Visual feedback
|
|
document.querySelectorAll(".icon-btn").forEach((btn) => {
|
|
(btn as HTMLElement).style.outline = "none";
|
|
(btn as HTMLElement).style.backgroundColor = "";
|
|
});
|
|
|
|
const selectedBtn = document.querySelector(
|
|
`.icon-btn[onclick="selectIcon('${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;
|
|
// Update hidden input with typed value
|
|
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] || [];
|
|
|
|
// Search in keywords OR emoji itself
|
|
const matches =
|
|
searchTerm === "" ||
|
|
emoji.includes(searchTerm) ||
|
|
keywords.some((keyword) =>
|
|
keyword.toLowerCase().includes(lowerSearchTerm),
|
|
);
|
|
|
|
(btn as HTMLElement).style.display = matches ? "" : "none";
|
|
});
|
|
}
|
|
|
|
// Export functions globally
|
|
export {
|
|
addbooksToAdd,
|
|
backToCollections,
|
|
closeCollectionModal,
|
|
createRule,
|
|
deleteRule,
|
|
filterCollectionBooks,
|
|
filterIcons,
|
|
hideAddBooksModal,
|
|
initCollectionDetail,
|
|
initColorSelection,
|
|
initIconSelection,
|
|
loadCollectionRules,
|
|
loadCollections,
|
|
navigateToCollection,
|
|
populateIconGrid,
|
|
removeBook,
|
|
removebooksToAdd,
|
|
searchBooksForCollections,
|
|
selectColor,
|
|
selectIcon,
|
|
showAddBooksModal,
|
|
showAllIcons,
|
|
setupHTMXAuth,
|
|
testRule,
|
|
toggleBookForRemoval,
|
|
toggleBookSelection,
|
|
updateSelectedCount,
|
|
};
|
|
|
|
Alpine.data("collections", () => ({
|
|
addbooksToAdd,
|
|
backToCollections,
|
|
closeCollectionModal,
|
|
createRule,
|
|
deleteRule,
|
|
filterCollectionBooks,
|
|
filterIcons,
|
|
hideAddBooksModal,
|
|
initCollectionDetail,
|
|
initColorSelection,
|
|
initIconSelection,
|
|
loadCollectionRules,
|
|
loadCollections,
|
|
navigateToCollection,
|
|
populateIconGrid,
|
|
removeBook,
|
|
removebooksToAdd,
|
|
searchBooksForCollections,
|
|
selectColor,
|
|
selectIcon,
|
|
showAddBooksModal,
|
|
showAllIcons,
|
|
testRule,
|
|
toggleBookForRemoval,
|
|
toggleBookSelection,
|
|
updateSelectedCount,
|
|
}));
|