feat(collections): add HTMX auth, icon picker, and navigation helpers
Add comprehensive TypeScript utilities for collections page functionality.
1. HTMX Authentication (setupHTMXAuth):
- Adds Authorization header to all HTMX requests automatically
- Listens for htmx:configRequest event on document.body
- Injects Bearer token from localStorage
- Eliminates need for hx-headers attributes on individual elements
2. Smart Card Navigation (navigateToCollection):
- Implements event delegation to distinguish button clicks from card clicks
- Checks event.target to determine what user clicked
- Returns early if button clicked (lets HTMX handle button actions)
- Navigates to collection detail page only when card body clicked
- Uses data-href attribute for navigation target
3. Color Selection Helpers:
- selectColor(): Updates hidden input and visual selection state
- closeCollectionModal(): Removes modal from DOM after HTMX swap
- initColorSelection(): Applies border color classes to collection cards
using borderClasses mapping (blue→border-blue-500, etc.)
4. Icon Picker with Search:
- Hardcoded iconData object: 30 emojis with searchable keywords
(e.g., "📚": ["book", "books", "library", "read", "reading"])
- populateIconGrid(): Dynamically generates icon buttons from iconData
- selectIcon(): Updates hidden input with selected emoji
- filterIcons(): Real-time search filtering by emoji OR keywords
- showAllIcons(): Clears search filter
- initIconSelection(): Auto-initializes after HTMX modal swap
(listens for htmx:afterSwap event on #modal-container)
5. HTMX Modal Initialization:
- setupHTMXModalInit(): Listens for modal loads via HTMX
- Auto-initializes icon picker when modal content swapped into
#modal-container
All functions exported to window object for onclick attribute access.
Auto-initializes on DOMContentLoaded or immediately if DOM ready.
Pattern consistency:
- Follows same pattern as toast.js (global exports, auto-init)
- Uses TypeScript type annotations
- No OOP (functional style per project guidelines)
- Server-side rendering with HTMX (no AJAX data fetching)
This commit is contained in:
@@ -206,3 +206,271 @@ function renderTestResults(results: unknown[]): void {
|
||||
(window as any).createRule = createRule;
|
||||
(window as any).deleteRule = deleteRule;
|
||||
(window as any).testRule = testRule;
|
||||
|
||||
// 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}`;
|
||||
});
|
||||
}
|
||||
// Auto-initialize HTMX auth when DOM is ready
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", setupHTMXAuth);
|
||||
} else {
|
||||
setupHTMXAuth();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
(window as any).navigateToCollection = navigateToCollection;
|
||||
|
||||
// ============================================================================
|
||||
// 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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initColorSelection);
|
||||
} else {
|
||||
initColorSelection();
|
||||
}
|
||||
// Export functions for global access
|
||||
(window as any).selectColor = selectColor;
|
||||
(window as any).closeCollectionModal = closeCollectionModal;
|
||||
(window as any).initColorSelection = initColorSelection;
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Auto-initialize when DOM is ready
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", setupHTMXModalInit);
|
||||
} else {
|
||||
setupHTMXModalInit();
|
||||
}
|
||||
|
||||
// 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
|
||||
const allIcons = Object.keys(iconData);
|
||||
|
||||
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";
|
||||
});
|
||||
}
|
||||
function showAllIcons(): void {
|
||||
const iconGrid = document.getElementById("icon-grid");
|
||||
if (!iconGrid) return;
|
||||
|
||||
const buttons = iconGrid.querySelectorAll(".icon-btn");
|
||||
buttons.forEach((btn) => {
|
||||
(btn as HTMLElement).style.display = "";
|
||||
});
|
||||
}
|
||||
function initIconSelection(): void {
|
||||
// First, populate the grid with all icons
|
||||
populateIconGrid();
|
||||
// Then, set the current selection
|
||||
const iconInput = document.getElementById(
|
||||
"collection-icon",
|
||||
) as HTMLInputElement;
|
||||
const searchInput = document.getElementById(
|
||||
"icon-search",
|
||||
) as HTMLInputElement;
|
||||
|
||||
if (iconInput && iconInput.value && searchInput) {
|
||||
searchInput.value = iconInput.value;
|
||||
selectIcon(iconInput.value);
|
||||
}
|
||||
}
|
||||
// Export for global access
|
||||
(window as any).selectIcon = selectIcon;
|
||||
(window as any).filterIcons = filterIcons;
|
||||
(window as any).showAllIcons = showAllIcons;
|
||||
(window as any).populateIconGrid = populateIconGrid;
|
||||
(window as any).initIconSelection = initIconSelection;
|
||||
|
||||
Reference in New Issue
Block a user