From 08b7f1307986d1102d1acdcf96ab6dd52081dc1a Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 1 Mar 2026 21:00:28 -0500 Subject: [PATCH] feat(collections): add HTMX auth, icon picker, and navigation helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/src/collections.ts | 268 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) diff --git a/web/src/collections.ts b/web/src/collections.ts index de213c2..f728938 100644 --- a/web/src/collections.ts +++ b/web/src/collections.ts @@ -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 = { + 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 = { + // 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;