/** * ThrillWiki Main JavaScript * * This file contains core functionality including: * - CSRF token handling for HTMX and AJAX requests * - Theme management * - Mobile menu functionality * - Flash message handling * - Tooltip initialization * - Global HTMX loading state management */ // ============================================================================= // CSRF Token Handling // ============================================================================= /** * Get CSRF token from cookies. * Django sets the CSRF token in a cookie named 'csrftoken'. * * @returns {string|null} The CSRF token or null if not found */ function getCookie(name) { let cookieValue = null; if (document.cookie && document.cookie !== '') { const cookies = document.cookie.split(';'); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); if (cookie.substring(0, name.length + 1) === (name + '=')) { cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); break; } } } return cookieValue; } /** * Configure HTMX to include CSRF token in all requests. * This handler runs before every HTMX request and adds the X-CSRFToken header. */ document.body.addEventListener('htmx:configRequest', (event) => { // Only add CSRF token for state-changing methods const method = event.detail.verb.toUpperCase(); if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { // Try to get token from cookie first const csrfToken = getCookie('csrftoken'); if (csrfToken) { event.detail.headers['X-CSRFToken'] = csrfToken; } else { // Fallback: try to get from meta tag or hidden input const metaToken = document.querySelector('meta[name="csrf-token"]'); const inputToken = document.querySelector('input[name="csrfmiddlewaretoken"]'); if (metaToken) { event.detail.headers['X-CSRFToken'] = metaToken.getAttribute('content'); } else if (inputToken) { event.detail.headers['X-CSRFToken'] = inputToken.value; } } } }); // ============================================================================= // Theme Handling // ============================================================================= // Theme handling document.addEventListener('DOMContentLoaded', () => { const themeToggle = document.getElementById('theme-toggle'); const html = document.documentElement; // Initialize toggle state based on current theme if (themeToggle) { themeToggle.checked = html.classList.contains('dark'); // Handle toggle changes themeToggle.addEventListener('change', function() { const isDark = this.checked; html.classList.toggle('dark', isDark); localStorage.setItem('theme', isDark ? 'dark' : 'light'); }); // Listen for system theme changes const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); mediaQuery.addEventListener('change', (e) => { if (!localStorage.getItem('theme')) { const isDark = e.matches; html.classList.toggle('dark', isDark); themeToggle.checked = isDark; } }); } }); // Handle search form submission document.addEventListener('submit', (e) => { if (e.target.matches('form[action*="search"]')) { const searchInput = e.target.querySelector('input[name="q"]'); if (!searchInput.value.trim()) { e.preventDefault(); } } }); // Mobile menu toggle with transitions document.addEventListener('DOMContentLoaded', () => { const mobileMenuBtn = document.getElementById('mobileMenuBtn'); const mobileMenu = document.getElementById('mobileMenu'); if (mobileMenuBtn && mobileMenu) { let isMenuOpen = false; const toggleMenu = () => { isMenuOpen = !isMenuOpen; mobileMenu.classList.toggle('show', isMenuOpen); mobileMenuBtn.setAttribute('aria-expanded', isMenuOpen.toString()); // Update icon const icon = mobileMenuBtn.querySelector('i'); icon.classList.remove(isMenuOpen ? 'fa-bars' : 'fa-times'); icon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars'); }; mobileMenuBtn.addEventListener('click', toggleMenu); // Close menu when clicking outside document.addEventListener('click', (e) => { if (isMenuOpen && !mobileMenu.contains(e.target) && !mobileMenuBtn.contains(e.target)) { toggleMenu(); } }); // Close menu when pressing escape document.addEventListener('keydown', (e) => { if (isMenuOpen && e.key === 'Escape') { toggleMenu(); } }); // Handle viewport changes const mediaQuery = window.matchMedia('(min-width: 1024px)'); mediaQuery.addEventListener('change', (e) => { if (e.matches && isMenuOpen) { toggleMenu(); } }); } }); // User dropdown functionality is handled by Alpine.js in the template // No additional JavaScript needed for dropdown functionality // Handle flash messages document.addEventListener('DOMContentLoaded', () => { const alerts = document.querySelectorAll('.alert'); alerts.forEach(alert => { setTimeout(() => { alert.style.opacity = '0'; setTimeout(() => alert.remove(), 300); }, 5000); }); }); // Initialize tooltips document.addEventListener('DOMContentLoaded', () => { const tooltips = document.querySelectorAll('[data-tooltip]'); tooltips.forEach(tooltip => { tooltip.addEventListener('mouseenter', (e) => { const text = e.target.getAttribute('data-tooltip'); const tooltipEl = document.createElement('div'); tooltipEl.className = 'absolute z-50 px-2 py-1 text-sm text-white bg-gray-900 rounded tooltip'; tooltipEl.textContent = text; document.body.appendChild(tooltipEl); const rect = e.target.getBoundingClientRect(); tooltipEl.style.top = rect.bottom + 5 + 'px'; tooltipEl.style.left = rect.left + (rect.width - tooltipEl.offsetWidth) / 2 + 'px'; }); tooltip.addEventListener('mouseleave', () => { const tooltips = document.querySelectorAll('.tooltip'); tooltips.forEach(t => t.remove()); }); }); }); // ============================================================================= // HTMX Loading State Management // ============================================================================= /** * Global HTMX Loading State Management * * Provides consistent loading state handling across the application: * - Adds 'htmx-loading' class to body during requests * - Manages button disabled states during form submissions * - Handles search input loading states with debouncing * - Provides skeleton screen swap utilities */ document.addEventListener('DOMContentLoaded', () => { // Track active HTMX requests let activeRequests = 0; /** * Add global loading class to body during HTMX requests */ document.body.addEventListener('htmx:beforeRequest', (evt) => { activeRequests++; document.body.classList.add('htmx-loading'); // Disable submit buttons within the target element const target = evt.target; if (target.tagName === 'FORM' || target.closest('form')) { const form = target.tagName === 'FORM' ? target : target.closest('form'); const submitBtn = form.querySelector('[type="submit"]'); if (submitBtn) { submitBtn.disabled = true; submitBtn.classList.add('htmx-request'); } } }); /** * Remove global loading class when request completes */ document.body.addEventListener('htmx:afterRequest', (evt) => { activeRequests--; if (activeRequests <= 0) { activeRequests = 0; document.body.classList.remove('htmx-loading'); } // Re-enable submit buttons const target = evt.target; if (target.tagName === 'FORM' || target.closest('form')) { const form = target.tagName === 'FORM' ? target : target.closest('form'); const submitBtn = form.querySelector('[type="submit"]'); if (submitBtn) { submitBtn.disabled = false; submitBtn.classList.remove('htmx-request'); } } }); /** * Handle search inputs with loading states * Automatically adds loading indicator to search inputs during HTMX requests */ document.querySelectorAll('input[type="search"], input[data-search]').forEach(input => { const wrapper = input.closest('.search-wrapper, .relative'); if (!wrapper) return; // Create loading indicator if it doesn't exist let indicator = wrapper.querySelector('.search-loading'); if (!indicator) { indicator = document.createElement('span'); indicator.className = 'search-loading htmx-indicator absolute right-3 top-1/2 -translate-y-1/2'; indicator.innerHTML = ''; wrapper.appendChild(indicator); } }); /** * Swap skeleton with content utility * Use data-skeleton-target to specify which skeleton to hide when content loads */ document.body.addEventListener('htmx:afterSwap', (evt) => { const skeletonTarget = evt.target.dataset.skeletonTarget; if (skeletonTarget) { const skeleton = document.querySelector(skeletonTarget); if (skeleton) { skeleton.style.display = 'none'; } } }); }); /** * Utility function to show skeleton and trigger HTMX load * @param {string} targetId - ID of the target element * @param {string} skeletonId - ID of the skeleton element */ window.showSkeletonAndLoad = function(targetId, skeletonId) { const target = document.getElementById(targetId); const skeleton = document.getElementById(skeletonId); if (skeleton) { skeleton.style.display = 'block'; } if (target) { htmx.trigger(target, 'load'); } }; /** * Utility function to replace content with skeleton during reload * @param {string} targetId - ID of the target element * @param {string} skeletonHtml - HTML string of skeleton to show */ window.reloadWithSkeleton = function(targetId, skeletonHtml) { const target = document.getElementById(targetId); if (target && skeletonHtml) { // Store original content temporarily target.dataset.originalContent = target.innerHTML; target.innerHTML = skeletonHtml; htmx.trigger(target, 'load'); } };