/** * Alpine.js Components for ThrillWiki * Enhanced components matching React frontend functionality */ // Flag to prevent duplicate component registration let componentsRegistered = false; // Debug logging to see what's happening console.log('Alpine components script is loading...'); // Try multiple approaches to ensure components register function registerComponents() { // Prevent duplicate registration if (componentsRegistered) { return; } if (typeof Alpine === 'undefined') { console.warn('Alpine.js not available yet, registration will retry'); return; } console.log('Registering Alpine.js components...'); // Mark as registered at the start to prevent race conditions componentsRegistered = true; // Theme Toggle Component Alpine.data('themeToggle', () => ({ theme: localStorage.getItem('theme') || 'system', init() { this.updateTheme(); // Watch for system theme changes window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (this.theme === 'system') { this.updateTheme(); } }); }, toggleTheme() { const themes = ['light', 'dark', 'system']; const currentIndex = themes.indexOf(this.theme); this.theme = themes[(currentIndex + 1) % themes.length]; localStorage.setItem('theme', this.theme); this.updateTheme(); }, updateTheme() { const root = document.documentElement; if (this.theme === 'dark' || (this.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) { root.classList.add('dark'); } else { root.classList.remove('dark'); } } })); // Search Component Alpine.data('searchComponent', () => ({ query: '', results: [], loading: false, showResults: false, async search() { if (this.query.length < 2) { this.results = []; this.showResults = false; return; } this.loading = true; try { // Use the same search endpoint as HTMX in the template const response = await fetch(`/search/parks/?q=${encodeURIComponent(this.query)}`, { headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } }); if (response.ok) { // Try to parse as JSON first, fallback to extracting from HTML const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { const data = await response.json(); this.results = data.results || []; } else { // Parse HTML response to extract search results const html = await response.text(); this.results = this.parseSearchResults(html); } this.showResults = this.results.length > 0; } else { this.results = []; this.showResults = false; } } catch (error) { console.error('Search error:', error); this.results = []; this.showResults = false; } finally { this.loading = false; } }, parseSearchResults(html) { // Helper method to extract search results from HTML response try { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const results = []; // Look for search result items in the HTML const resultElements = doc.querySelectorAll('[data-search-result], .search-result-item, .park-item'); resultElements.forEach(element => { const title = element.querySelector('h3, .title, [data-title]')?.textContent?.trim(); const url = element.querySelector('a')?.getAttribute('href'); const description = element.querySelector('.description, .excerpt, p')?.textContent?.trim(); if (title && url) { results.push({ title, url, description: description || '' }); } }); return results.slice(0, 10); // Limit to 10 results } catch (error) { console.error('Error parsing search results:', error); return []; } }, selectResult(result) { window.location.href = result.url; this.showResults = false; this.query = ''; }, clearSearch() { this.query = ''; this.results = []; this.showResults = false; } })); // Browse Menu Component Alpine.data('browseMenu', () => ({ open: false, toggle() { this.open = !this.open; }, close() { this.open = false; } })); // Mobile Menu Component Alpine.data('mobileMenu', () => ({ open: false, toggle() { this.open = !this.open; // Prevent body scroll when menu is open if (this.open) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; } }, close() { this.open = false; document.body.style.overflow = ''; } })); // User Menu Component Alpine.data('userMenu', () => ({ open: false, toggle() { this.open = !this.open; }, close() { this.open = false; } })); // Modal Component Alpine.data('modal', (initialOpen = false) => ({ open: initialOpen, show() { this.open = true; document.body.style.overflow = 'hidden'; }, hide() { this.open = false; document.body.style.overflow = ''; }, toggle() { if (this.open) { this.hide(); } else { this.show(); } } })); // Dropdown Component Alpine.data('dropdown', (initialOpen = false) => ({ open: initialOpen, toggle() { this.open = !this.open; }, close() { this.open = false; }, show() { this.open = true; } })); // Tabs Component Alpine.data('tabs', (defaultTab = 0) => ({ activeTab: defaultTab, setTab(index) { this.activeTab = index; }, isActive(index) { return this.activeTab === index; } })); // Accordion Component Alpine.data('accordion', (allowMultiple = false) => ({ openItems: [], toggle(index) { if (this.isOpen(index)) { this.openItems = this.openItems.filter(item => item !== index); } else { if (allowMultiple) { this.openItems.push(index); } else { this.openItems = [index]; } } }, isOpen(index) { return this.openItems.includes(index); }, open(index) { if (!this.isOpen(index)) { if (allowMultiple) { this.openItems.push(index); } else { this.openItems = [index]; } } }, close(index) { this.openItems = this.openItems.filter(item => item !== index); } })); // Form Component with Validation Alpine.data('form', (initialData = {}) => ({ data: initialData, errors: {}, loading: false, setField(field, value) { this.data[field] = value; // Clear error when user starts typing if (this.errors[field]) { delete this.errors[field]; } }, setError(field, message) { this.errors[field] = message; }, clearErrors() { this.errors = {}; }, hasError(field) { return !!this.errors[field]; }, getError(field) { return this.errors[field] || ''; }, async submit(url, options = {}) { this.loading = true; this.clearErrors(); try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '', ...options.headers }, body: JSON.stringify(this.data), ...options }); const result = await response.json(); if (!response.ok) { if (result.errors) { this.errors = result.errors; } throw new Error(result.message || 'Form submission failed'); } return result; } catch (error) { console.error('Form submission error:', error); throw error; } finally { this.loading = false; } } })); // Pagination Component Alpine.data('pagination', (initialPage = 1, totalPages = 1) => ({ currentPage: initialPage, totalPages: totalPages, goToPage(page) { if (page >= 1 && page <= this.totalPages) { this.currentPage = page; } }, nextPage() { this.goToPage(this.currentPage + 1); }, prevPage() { this.goToPage(this.currentPage - 1); }, hasNext() { return this.currentPage < this.totalPages; }, hasPrev() { return this.currentPage > 1; }, getPages() { const pages = []; const start = Math.max(1, this.currentPage - 2); const end = Math.min(this.totalPages, this.currentPage + 2); for (let i = start; i <= end; i++) { pages.push(i); } return pages; } })); // Enhanced Authentication Modal Component Alpine.data('authModal', (defaultMode = 'login') => ({ open: false, mode: defaultMode, // 'login' or 'register' showPassword: false, socialProviders: [ {id: 'google', name: 'Google', auth_url: '/accounts/google/login/'}, {id: 'discord', name: 'Discord', auth_url: '/accounts/discord/login/'} ], socialLoading: false, // Login form data loginForm: { username: '', password: '' }, loginLoading: false, loginError: '', // Register form data registerForm: { first_name: '', last_name: '', email: '', username: '', password1: '', password2: '' }, registerLoading: false, registerError: '', init() { // Listen for auth modal events this.$watch('open', (value) => { if (value) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; this.resetForms(); } }); }, show(mode = 'login') { this.mode = mode; this.open = true; }, close() { this.open = false; }, switchToLogin() { this.mode = 'login'; this.resetForms(); }, switchToRegister() { this.mode = 'register'; this.resetForms(); }, resetForms() { this.loginForm = { username: '', password: '' }; this.registerForm = { first_name: '', last_name: '', email: '', username: '', password1: '', password2: '' }; this.loginError = ''; this.registerError = ''; this.showPassword = false; }, async handleLogin() { if (!this.loginForm.username || !this.loginForm.password) { this.loginError = 'Please fill in all fields'; return; } this.loginLoading = true; this.loginError = ''; try { const response = await fetch('/accounts/login/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': this.getCSRFToken(), 'X-Requested-With': 'XMLHttpRequest' }, body: new URLSearchParams({ login: this.loginForm.username, password: this.loginForm.password, next: window.location.pathname }), redirect: 'manual' // Handle redirects manually }); // Django allauth returns 302 redirect on successful login if (response.status === 302 || (response.ok && response.status === 200)) { // Check if login was successful by trying to parse response try { const html = await response.text(); // If response contains error messages, login failed if (html.includes('errorlist') || html.includes('alert-danger') || html.includes('invalid')) { this.loginError = this.extractErrorFromHTML(html) || 'Login failed. Please check your credentials.'; } else { // Login successful - reload page to update auth state window.location.reload(); } } catch { // If we can't parse response, assume success and reload window.location.reload(); } } else if (response.status === 200) { // Form validation errors - parse HTML response for error messages const html = await response.text(); this.loginError = this.extractErrorFromHTML(html) || 'Login failed. Please check your credentials.'; } else { this.loginError = 'Login failed. Please check your credentials.'; } } catch (error) { console.error('Login error:', error); this.loginError = 'An error occurred. Please try again.'; } finally { this.loginLoading = false; } }, async handleRegister() { if (!this.registerForm.first_name || !this.registerForm.last_name || !this.registerForm.email || !this.registerForm.username || !this.registerForm.password1 || !this.registerForm.password2) { this.registerError = 'Please fill in all fields'; return; } if (this.registerForm.password1 !== this.registerForm.password2) { this.registerError = 'Passwords do not match'; return; } this.registerLoading = true; this.registerError = ''; try { const response = await fetch('/accounts/signup/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': this.getCSRFToken(), 'X-Requested-With': 'XMLHttpRequest' }, body: new URLSearchParams({ first_name: this.registerForm.first_name, last_name: this.registerForm.last_name, email: this.registerForm.email, username: this.registerForm.username, password1: this.registerForm.password1, password2: this.registerForm.password2 }), redirect: 'manual' }); if (response.status === 302 || response.ok) { try { const html = await response.text(); // Check if registration was successful if (html.includes('errorlist') || html.includes('alert-danger') || html.includes('invalid')) { this.registerError = this.extractErrorFromHTML(html) || 'Registration failed. Please try again.'; } else { // Registration successful this.close(); Alpine.store('toast').success('Account created successfully! Please check your email to verify your account.'); } } catch { // Assume success if we can't parse response this.close(); Alpine.store('toast').success('Account created successfully! Please check your email to verify your account.'); } } else if (response.status === 200) { // Form validation errors const html = await response.text(); this.registerError = this.extractErrorFromHTML(html) || 'Registration failed. Please try again.'; } else { this.registerError = 'Registration failed. Please try again.'; } } catch (error) { console.error('Registration error:', error); this.registerError = 'An error occurred. Please try again.'; } finally { this.registerLoading = false; } }, handleSocialLogin(providerId) { const provider = this.socialProviders.find(p => p.id === providerId); if (!provider) { Alpine.store('toast').error(`Social provider ${providerId} not found.`); return; } // Redirect to social auth URL window.location.href = provider.auth_url; }, extractErrorFromHTML(html) { try { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // Look for error messages in various formats const errorSelectors = [ '.errorlist li', '.alert-danger', '.invalid-feedback', '.form-error', '[class*="error"]', '.field-error' ]; for (const selector of errorSelectors) { const errorElements = doc.querySelectorAll(selector); if (errorElements.length > 0) { return Array.from(errorElements) .map(el => el.textContent.trim()) .filter(text => text.length > 0) .join(' '); } } return null; } catch (error) { console.error('Error parsing HTML for error messages:', error); return null; } }, getCSRFToken() { const token = document.querySelector('[name=csrfmiddlewaretoken]')?.value || document.querySelector('meta[name=csrf-token]')?.getAttribute('content') || document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1]; return token || ''; } })); // Global Store for App State Alpine.store('app', { user: null, theme: 'system', searchQuery: '', notifications: [], setUser(user) { this.user = user; }, setTheme(theme) { this.theme = theme; localStorage.setItem('theme', theme); }, addNotification(notification) { this.notifications.push({ id: Date.now(), ...notification }); }, removeNotification(id) { this.notifications = this.notifications.filter(n => n.id !== id); } }); // Global Toast Store Alpine.store('toast', { toasts: [], show(message, type = 'info', duration = 5000) { const id = Date.now() + Math.random(); const toast = { id, message, type, visible: true, progress: 100 }; this.toasts.push(toast); if (duration > 0) { const interval = setInterval(() => { toast.progress -= (100 / (duration / 100)); if (toast.progress <= 0) { clearInterval(interval); this.hide(id); } }, 100); } return id; }, hide(id) { const toast = this.toasts.find(t => t.id === id); if (toast) { toast.visible = false; setTimeout(() => { this.toasts = this.toasts.filter(t => t.id !== id); }, 300); } }, success(message, duration = 5000) { return this.show(message, 'success', duration); }, error(message, duration = 7000) { return this.show(message, 'error', duration); }, warning(message, duration = 6000) { return this.show(message, 'warning', duration); }, info(message, duration = 5000) { return this.show(message, 'info', duration); } }); console.log('Alpine.js components registered successfully'); } // Try multiple registration approaches document.addEventListener('alpine:init', registerComponents); document.addEventListener('DOMContentLoaded', registerComponents); // Fallback - try after a delay setTimeout(() => { if (typeof Alpine !== 'undefined' && !componentsRegistered) { registerComponents(); } }, 100);