/** * ThrillWiki Enhanced JavaScript * Advanced interactions, animations, and UI enhancements * Last Updated: 2025-01-15 */ // Global ThrillWiki namespace window.ThrillWiki = window.ThrillWiki || {}; (function(TW) { 'use strict'; // Configuration TW.config = { animationDuration: 300, scrollOffset: 80, debounceDelay: 300, apiEndpoints: { search: '/api/search/', favorites: '/api/favorites/', notifications: '/api/notifications/' } }; // Utility functions TW.utils = { // Debounce function for performance debounce: function(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }, // Throttle function for scroll events throttle: function(func, limit) { let inThrottle; return function() { const args = arguments; const context = this; if (!inThrottle) { func.apply(context, args); inThrottle = true; setTimeout(() => inThrottle = false, limit); } }; }, // Smooth scroll to element scrollTo: function(element, offset = TW.config.scrollOffset) { const targetPosition = element.offsetTop - offset; window.scrollTo({ top: targetPosition, behavior: 'smooth' }); }, // Check if element is in viewport isInViewport: function(element) { const rect = element.getBoundingClientRect(); return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) ); }, // Format numbers with commas formatNumber: function(num) { return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); }, // Generate unique ID generateId: function() { return 'tw-' + Math.random().toString(36).substr(2, 9); } }; // Animation system TW.animations = { // Fade in animation fadeIn: function(element, duration = TW.config.animationDuration) { element.style.opacity = '0'; element.style.display = 'block'; const fadeEffect = setInterval(() => { if (!element.style.opacity) { element.style.opacity = 0; } if (element.style.opacity < 1) { element.style.opacity = parseFloat(element.style.opacity) + 0.1; } else { clearInterval(fadeEffect); } }, duration / 10); }, // Slide in from bottom slideInUp: function(element, duration = TW.config.animationDuration) { element.style.transform = 'translateY(30px)'; element.style.opacity = '0'; element.style.transition = `all ${duration}ms cubic-bezier(0.16, 1, 0.3, 1)`; setTimeout(() => { element.style.transform = 'translateY(0)'; element.style.opacity = '1'; }, 10); }, // Pulse effect pulse: function(element, intensity = 1.05) { element.style.transition = 'transform 0.15s ease-out'; element.style.transform = `scale(${intensity})`; setTimeout(() => { element.style.transform = 'scale(1)'; }, 150); }, // Shake effect for errors shake: function(element) { element.style.animation = 'shake 0.5s ease-in-out'; setTimeout(() => { element.style.animation = ''; }, 500); } }; // Enhanced search functionality TW.search = { init: function() { this.setupQuickSearch(); this.setupAdvancedSearch(); this.setupSearchSuggestions(); }, setupQuickSearch: function() { const quickSearchInputs = document.querySelectorAll('[data-quick-search]'); quickSearchInputs.forEach(input => { const debouncedSearch = TW.utils.debounce(this.performQuickSearch.bind(this), TW.config.debounceDelay); input.addEventListener('input', (e) => { const query = e.target.value.trim(); if (query.length >= 2) { debouncedSearch(query, e.target); } else { this.clearSearchResults(e.target); } }); // Handle keyboard navigation input.addEventListener('keydown', this.handleSearchKeyboard.bind(this)); }); }, performQuickSearch: function(query, inputElement) { const resultsContainer = document.getElementById(inputElement.dataset.quickSearch); if (!resultsContainer) return; // Show loading state resultsContainer.innerHTML = this.getLoadingHTML(); resultsContainer.classList.remove('hidden'); // Perform search fetch(`${TW.config.apiEndpoints.search}?q=${encodeURIComponent(query)}`) .then(response => response.json()) .then(data => { this.displaySearchResults(data, resultsContainer); }) .catch(error => { console.error('Search error:', error); resultsContainer.innerHTML = this.getErrorHTML(); }); }, displaySearchResults: function(data, container) { if (!data.results || data.results.length === 0) { container.innerHTML = this.getNoResultsHTML(); return; } let html = '
'; // Group results by type const groupedResults = this.groupResultsByType(data.results); Object.keys(groupedResults).forEach(type => { if (groupedResults[type].length > 0) { html += `

${this.getTypeTitle(type)}

`; groupedResults[type].forEach(result => { html += this.getResultItemHTML(result); }); html += '
'; } }); html += '
'; container.innerHTML = html; // Add click handlers this.attachResultClickHandlers(container); }, getResultItemHTML: function(result) { return `
${result.name}
${result.subtitle || ''}
${result.image ? `
${result.name}
` : ''}
`; }, groupResultsByType: function(results) { return results.reduce((groups, result) => { const type = result.type || 'other'; if (!groups[type]) groups[type] = []; groups[type].push(result); return groups; }, {}); }, getTypeTitle: function(type) { const titles = { 'park': 'Theme Parks', 'ride': 'Rides & Attractions', 'location': 'Locations', 'other': 'Other Results' }; return titles[type] || 'Results'; }, getTypeIcon: function(type) { const icons = { 'park': 'map-marked-alt', 'ride': 'rocket', 'location': 'map-marker-alt', 'other': 'search' }; return icons[type] || 'search'; }, getLoadingHTML: function() { return `
Searching...
`; }, getNoResultsHTML: function() { return `
No results found
`; }, getErrorHTML: function() { return `
Search error. Please try again.
`; }, attachResultClickHandlers: function(container) { const resultItems = container.querySelectorAll('.search-result-item'); resultItems.forEach(item => { item.addEventListener('click', (e) => { const url = item.dataset.url; if (url) { // Use HTMX if available, otherwise navigate normally if (window.htmx) { htmx.ajax('GET', url, { target: '#main-content', swap: 'innerHTML transition:true' }); } else { window.location.href = url; } // Clear search this.clearSearchResults(container.previousElementSibling); } }); }); }, clearSearchResults: function(inputElement) { const resultsContainer = document.getElementById(inputElement.dataset.quickSearch); if (resultsContainer) { resultsContainer.classList.add('hidden'); resultsContainer.innerHTML = ''; } }, handleSearchKeyboard: function(e) { // Handle escape key to close results if (e.key === 'Escape') { this.clearSearchResults(e.target); } } }; // Enhanced card interactions TW.cards = { init: function() { this.setupCardHovers(); this.setupFavoriteButtons(); this.setupCardAnimations(); }, setupCardHovers: function() { const cards = document.querySelectorAll('.card-park, .card-ride, .card-feature'); cards.forEach(card => { card.addEventListener('mouseenter', () => { this.onCardHover(card); }); card.addEventListener('mouseleave', () => { this.onCardLeave(card); }); }); }, onCardHover: function(card) { // Add subtle glow effect card.style.boxShadow = '0 20px 40px rgba(99, 102, 241, 0.15)'; // Animate card image if present const image = card.querySelector('.card-park-image, .card-ride-image'); if (image) { image.style.transform = 'scale(1.05)'; } // Show hidden elements const hiddenElements = card.querySelectorAll('.opacity-0'); hiddenElements.forEach(el => { el.style.opacity = '1'; el.style.transform = 'translateY(0)'; }); }, onCardLeave: function(card) { // Reset styles card.style.boxShadow = ''; const image = card.querySelector('.card-park-image, .card-ride-image'); if (image) { image.style.transform = ''; } }, setupFavoriteButtons: function() { document.addEventListener('click', (e) => { if (e.target.closest('[data-favorite-toggle]')) { e.preventDefault(); e.stopPropagation(); const button = e.target.closest('[data-favorite-toggle]'); this.toggleFavorite(button); } }); }, toggleFavorite: function(button) { const itemId = button.dataset.favoriteToggle; const itemType = button.dataset.favoriteType || 'park'; // Optimistic UI update const icon = button.querySelector('i'); const isFavorited = icon.classList.contains('fas'); if (isFavorited) { icon.classList.remove('fas', 'text-red-500'); icon.classList.add('far', 'text-neutral-600', 'dark:text-neutral-400'); } else { icon.classList.remove('far', 'text-neutral-600', 'dark:text-neutral-400'); icon.classList.add('fas', 'text-red-500'); } // Animate button TW.animations.pulse(button, 1.2); // Send request to server fetch(`${TW.config.apiEndpoints.favorites}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.getCSRFToken() }, body: JSON.stringify({ item_id: itemId, item_type: itemType, action: isFavorited ? 'remove' : 'add' }) }) .then(response => response.json()) .then(data => { if (!data.success) { // Revert optimistic update if (isFavorited) { icon.classList.remove('far', 'text-neutral-600', 'dark:text-neutral-400'); icon.classList.add('fas', 'text-red-500'); } else { icon.classList.remove('fas', 'text-red-500'); icon.classList.add('far', 'text-neutral-600', 'dark:text-neutral-400'); } TW.notifications.show('Error updating favorite', 'error'); } }) .catch(error => { console.error('Favorite toggle error:', error); TW.notifications.show('Error updating favorite', 'error'); }); }, getCSRFToken: function() { const token = document.querySelector('[name=csrfmiddlewaretoken]'); return token ? token.value : ''; } }; // Enhanced notifications system TW.notifications = { container: null, init: function() { this.createContainer(); this.setupAutoHide(); }, createContainer: function() { if (!this.container) { this.container = document.createElement('div'); this.container.id = 'tw-notifications'; this.container.className = 'fixed top-4 right-4 z-50 space-y-4'; document.body.appendChild(this.container); } }, show: function(message, type = 'info', duration = 5000) { const notification = this.createNotification(message, type); this.container.appendChild(notification); // Animate in setTimeout(() => { notification.classList.add('show'); }, 10); // Auto hide if (duration > 0) { setTimeout(() => { this.hide(notification); }, duration); } return notification; }, createNotification: function(message, type) { const notification = document.createElement('div'); notification.className = `notification notification-${type}`; const typeIcons = { 'success': 'check-circle', 'error': 'exclamation-circle', 'warning': 'exclamation-triangle', 'info': 'info-circle' }; notification.innerHTML = `
${message}
`; return notification; }, hide: function(notification) { notification.classList.add('hide'); setTimeout(() => { if (notification.parentNode) { notification.parentNode.removeChild(notification); } }, 300); }, setupAutoHide: function() { // Auto-hide notifications on page navigation if (window.htmx) { document.addEventListener('htmx:beforeRequest', () => { const notifications = this.container.querySelectorAll('.notification'); notifications.forEach(notification => { this.hide(notification); }); }); } } }; // Enhanced scroll effects TW.scroll = { init: function() { this.setupParallax(); this.setupRevealAnimations(); this.setupScrollToTop(); }, setupParallax: function() { const parallaxElements = document.querySelectorAll('[data-parallax]'); if (parallaxElements.length > 0) { const handleScroll = TW.utils.throttle(() => { const scrolled = window.pageYOffset; parallaxElements.forEach(element => { const speed = parseFloat(element.dataset.parallax) || 0.5; const yPos = -(scrolled * speed); element.style.transform = `translateY(${yPos}px)`; }); }, 16); // ~60fps window.addEventListener('scroll', handleScroll); } }, setupRevealAnimations: function() { const revealElements = document.querySelectorAll('[data-reveal]'); if (revealElements.length > 0) { const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const element = entry.target; const animationType = element.dataset.reveal || 'fadeIn'; const delay = parseInt(element.dataset.revealDelay) || 0; setTimeout(() => { element.classList.add('revealed'); if (TW.animations[animationType]) { TW.animations[animationType](element); } }, delay); observer.unobserve(element); } }); }, { threshold: 0.1, rootMargin: '0px 0px -50px 0px' }); revealElements.forEach(element => { observer.observe(element); }); } }, setupScrollToTop: function() { const scrollToTopBtn = document.createElement('button'); scrollToTopBtn.id = 'scroll-to-top'; scrollToTopBtn.className = 'fixed bottom-8 right-8 w-12 h-12 bg-thrill-primary text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-300 opacity-0 pointer-events-none z-40'; scrollToTopBtn.innerHTML = ''; scrollToTopBtn.setAttribute('aria-label', 'Scroll to top'); document.body.appendChild(scrollToTopBtn); const handleScroll = TW.utils.throttle(() => { if (window.pageYOffset > 300) { scrollToTopBtn.classList.remove('opacity-0', 'pointer-events-none'); scrollToTopBtn.classList.add('opacity-100'); } else { scrollToTopBtn.classList.add('opacity-0', 'pointer-events-none'); scrollToTopBtn.classList.remove('opacity-100'); } }, 100); window.addEventListener('scroll', handleScroll); scrollToTopBtn.addEventListener('click', () => { window.scrollTo({ top: 0, behavior: 'smooth' }); }); } }; // Enhanced form handling TW.forms = { init: function() { this.setupFormValidation(); this.setupFormAnimations(); this.setupFileUploads(); }, setupFormValidation: function() { const forms = document.querySelectorAll('form[data-validate]'); forms.forEach(form => { form.addEventListener('submit', (e) => { if (!this.validateForm(form)) { e.preventDefault(); TW.animations.shake(form); } }); // Real-time validation const inputs = form.querySelectorAll('input, textarea, select'); inputs.forEach(input => { input.addEventListener('blur', () => { this.validateField(input); }); }); }); }, validateForm: function(form) { let isValid = true; const inputs = form.querySelectorAll('input[required], textarea[required], select[required]'); inputs.forEach(input => { if (!this.validateField(input)) { isValid = false; } }); return isValid; }, validateField: function(field) { const value = field.value.trim(); const isRequired = field.hasAttribute('required'); const type = field.type; let isValid = true; let errorMessage = ''; // Required validation if (isRequired && !value) { isValid = false; errorMessage = 'This field is required'; } // Type-specific validation if (value && type === 'email') { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(value)) { isValid = false; errorMessage = 'Please enter a valid email address'; } } // Update field appearance this.updateFieldValidation(field, isValid, errorMessage); return isValid; }, updateFieldValidation: function(field, isValid, errorMessage) { const fieldGroup = field.closest('.form-group'); if (!fieldGroup) return; // Remove existing error states field.classList.remove('form-input-error'); const existingError = fieldGroup.querySelector('.form-error'); if (existingError) { existingError.remove(); } if (!isValid) { field.classList.add('form-input-error'); const errorElement = document.createElement('div'); errorElement.className = 'form-error'; errorElement.textContent = errorMessage; fieldGroup.appendChild(errorElement); TW.animations.slideInUp(errorElement, 200); } } }; // Initialize all modules TW.init = function() { // Wait for DOM to be ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', this.initModules.bind(this)); } else { this.initModules(); } }; TW.initModules = function() { console.log('🎢 ThrillWiki Enhanced JavaScript initialized'); // Initialize all modules TW.search.init(); TW.cards.init(); TW.notifications.init(); TW.scroll.init(); TW.forms.init(); // Setup HTMX enhancements if (window.htmx) { this.setupHTMXEnhancements(); } // Setup global error handling this.setupErrorHandling(); }; TW.setupHTMXEnhancements = function() { // Global HTMX configuration htmx.config.globalViewTransitions = true; htmx.config.scrollBehavior = 'smooth'; // Enhanced loading states document.addEventListener('htmx:beforeRequest', (e) => { const target = e.target; target.classList.add('htmx-request'); }); document.addEventListener('htmx:afterRequest', (e) => { const target = e.target; target.classList.remove('htmx-request'); }); // Re-initialize components after HTMX swaps document.addEventListener('htmx:afterSwap', (e) => { // Re-initialize cards in the swapped content const newCards = e.detail.target.querySelectorAll('.card-park, .card-ride, .card-feature'); if (newCards.length > 0) { TW.cards.setupCardHovers(); } // Re-initialize forms const newForms = e.detail.target.querySelectorAll('form[data-validate]'); if (newForms.length > 0) { TW.forms.setupFormValidation(); } }); }; TW.setupErrorHandling = function() { window.addEventListener('error', (e) => { console.error('ThrillWiki Error:', e.error); // Could send to error tracking service here }); window.addEventListener('unhandledrejection', (e) => { console.error('ThrillWiki Promise Rejection:', e.reason); // Could send to error tracking service here }); }; // Auto-initialize TW.init(); })(window.ThrillWiki); // Export for module systems if (typeof module !== 'undefined' && module.exports) { module.exports = window.ThrillWiki; }