mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 02:11:08 -05:00
Refactor templates to utilize AlpineJS for state management and interactions, replacing custom JavaScript. Updated navigation links for parks and rides, streamlined mobile filter functionality, and enhanced advanced search features. Removed legacy JavaScript code for improved performance and maintainability.
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Get all alert elements
|
||||
const alerts = document.querySelectorAll('.alert');
|
||||
|
||||
// For each alert
|
||||
alerts.forEach(alert => {
|
||||
// After 5 seconds
|
||||
setTimeout(() => {
|
||||
// Add slideOut animation
|
||||
alert.style.animation = 'slideOut 0.5s ease-out forwards';
|
||||
|
||||
// Remove the alert after animation completes
|
||||
setTimeout(() => {
|
||||
alert.remove();
|
||||
}, 500);
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
@@ -1,746 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
@@ -1,665 +0,0 @@
|
||||
/**
|
||||
* ThrillWiki Dark Mode Maps - Dark Mode Integration for Maps
|
||||
*
|
||||
* This module provides comprehensive dark mode support for maps,
|
||||
* including automatic theme switching, dark tile layers, and consistent styling
|
||||
*/
|
||||
|
||||
class DarkModeMaps {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
enableAutoThemeDetection: true,
|
||||
enableSystemPreference: true,
|
||||
enableStoredPreference: true,
|
||||
storageKey: 'thrillwiki_dark_mode',
|
||||
transitionDuration: 300,
|
||||
tileProviders: {
|
||||
light: {
|
||||
osm: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
cartodb: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
},
|
||||
dark: {
|
||||
osm: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
cartodb: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
}
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
this.currentTheme = 'light';
|
||||
this.mapInstances = new Set();
|
||||
this.tileLayers = new Map();
|
||||
this.observer = null;
|
||||
this.mediaQuery = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize dark mode support
|
||||
*/
|
||||
init() {
|
||||
this.detectCurrentTheme();
|
||||
this.setupThemeObserver();
|
||||
this.setupSystemPreferenceDetection();
|
||||
this.setupStorageSync();
|
||||
this.setupMapThemeStyles();
|
||||
this.bindEventHandlers();
|
||||
|
||||
console.log('Dark mode maps initialized with theme:', this.currentTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect current theme from DOM
|
||||
*/
|
||||
detectCurrentTheme() {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
this.currentTheme = 'dark';
|
||||
} else {
|
||||
this.currentTheme = 'light';
|
||||
}
|
||||
|
||||
// Check stored preference
|
||||
if (this.options.enableStoredPreference) {
|
||||
const stored = localStorage.getItem(this.options.storageKey);
|
||||
if (stored && ['light', 'dark', 'auto'].includes(stored)) {
|
||||
this.applyStoredPreference(stored);
|
||||
}
|
||||
}
|
||||
|
||||
// Check system preference if auto
|
||||
if (this.options.enableSystemPreference && this.getStoredPreference() === 'auto') {
|
||||
this.applySystemPreference();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup theme observer to watch for changes
|
||||
*/
|
||||
setupThemeObserver() {
|
||||
this.observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
const newTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
|
||||
if (newTheme !== this.currentTheme) {
|
||||
this.handleThemeChange(newTheme);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup system preference detection
|
||||
*/
|
||||
setupSystemPreferenceDetection() {
|
||||
if (!this.options.enableSystemPreference) return;
|
||||
|
||||
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleSystemChange = (e) => {
|
||||
if (this.getStoredPreference() === 'auto') {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
this.setTheme(newTheme);
|
||||
}
|
||||
};
|
||||
|
||||
// Modern browsers
|
||||
if (this.mediaQuery.addEventListener) {
|
||||
this.mediaQuery.addEventListener('change', handleSystemChange);
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
this.mediaQuery.addListener(handleSystemChange);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup storage synchronization
|
||||
*/
|
||||
setupStorageSync() {
|
||||
// Listen for storage changes from other tabs
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === this.options.storageKey) {
|
||||
const newPreference = e.newValue;
|
||||
if (newPreference) {
|
||||
this.applyStoredPreference(newPreference);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup map theme styles
|
||||
*/
|
||||
setupMapThemeStyles() {
|
||||
if (document.getElementById('dark-mode-map-styles')) return;
|
||||
|
||||
const styles = `
|
||||
<style id="dark-mode-map-styles">
|
||||
/* Light theme map styles */
|
||||
.map-container {
|
||||
transition: filter ${this.options.transitionDuration}ms ease;
|
||||
}
|
||||
|
||||
/* Dark theme map styles */
|
||||
.dark .map-container {
|
||||
filter: brightness(0.9) contrast(1.1);
|
||||
}
|
||||
|
||||
/* Dark theme popup styles */
|
||||
.dark .leaflet-popup-content-wrapper {
|
||||
background: #1F2937;
|
||||
color: #F9FAFB;
|
||||
border: 1px solid #374151;
|
||||
}
|
||||
|
||||
.dark .leaflet-popup-tip {
|
||||
background: #1F2937;
|
||||
border: 1px solid #374151;
|
||||
}
|
||||
|
||||
.dark .leaflet-popup-close-button {
|
||||
color: #D1D5DB;
|
||||
}
|
||||
|
||||
.dark .leaflet-popup-close-button:hover {
|
||||
color: #F9FAFB;
|
||||
}
|
||||
|
||||
/* Dark theme control styles */
|
||||
.dark .leaflet-control-zoom a {
|
||||
background-color: #374151;
|
||||
border-color: #4B5563;
|
||||
color: #F9FAFB;
|
||||
}
|
||||
|
||||
.dark .leaflet-control-zoom a:hover {
|
||||
background-color: #4B5563;
|
||||
}
|
||||
|
||||
.dark .leaflet-control-attribution {
|
||||
background: rgba(31, 41, 55, 0.8);
|
||||
color: #D1D5DB;
|
||||
}
|
||||
|
||||
/* Dark theme marker cluster styles */
|
||||
.dark .cluster-marker-inner {
|
||||
background: #1E40AF;
|
||||
border-color: #1F2937;
|
||||
}
|
||||
|
||||
.dark .cluster-marker-medium .cluster-marker-inner {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.dark .cluster-marker-large .cluster-marker-inner {
|
||||
background: #DC2626;
|
||||
}
|
||||
|
||||
/* Dark theme location marker styles */
|
||||
.dark .location-marker-inner {
|
||||
border-color: #1F2937;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
/* Dark theme filter panel styles */
|
||||
.dark .filter-chip.active {
|
||||
background: #1E40AF;
|
||||
color: #F9FAFB;
|
||||
}
|
||||
|
||||
.dark .filter-chip.inactive {
|
||||
background: #374151;
|
||||
color: #D1D5DB;
|
||||
}
|
||||
|
||||
.dark .filter-chip.inactive:hover {
|
||||
background: #4B5563;
|
||||
}
|
||||
|
||||
/* Dark theme road trip styles */
|
||||
.dark .park-item {
|
||||
background: #374151;
|
||||
border-color: #4B5563;
|
||||
}
|
||||
|
||||
.dark .park-item:hover {
|
||||
background: #4B5563;
|
||||
}
|
||||
|
||||
/* Dark theme search results */
|
||||
.dark .search-result-item {
|
||||
background: #374151;
|
||||
border-color: #4B5563;
|
||||
}
|
||||
|
||||
.dark .search-result-item:hover {
|
||||
background: #4B5563;
|
||||
}
|
||||
|
||||
/* Dark theme loading indicators */
|
||||
.dark .htmx-indicator {
|
||||
color: #D1D5DB;
|
||||
}
|
||||
|
||||
/* Theme transition effects */
|
||||
.theme-transition {
|
||||
transition: background-color ${this.options.transitionDuration}ms ease,
|
||||
color ${this.options.transitionDuration}ms ease,
|
||||
border-color ${this.options.transitionDuration}ms ease;
|
||||
}
|
||||
|
||||
/* Dark theme toggle button */
|
||||
.theme-toggle {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
background: #E5E7EB;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color ${this.options.transitionDuration}ms ease;
|
||||
}
|
||||
|
||||
.dark .theme-toggle {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.theme-toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform ${this.options.transitionDuration}ms ease;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.dark .theme-toggle::after {
|
||||
transform: translateX(24px);
|
||||
background: #F9FAFB;
|
||||
}
|
||||
|
||||
/* Theme icons */
|
||||
.theme-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 12px;
|
||||
transition: opacity ${this.options.transitionDuration}ms ease;
|
||||
}
|
||||
|
||||
.theme-icon.sun {
|
||||
left: 4px;
|
||||
color: #F59E0B;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.theme-icon.moon {
|
||||
right: 4px;
|
||||
color: #6366F1;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dark .theme-icon.sun {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dark .theme-icon.moon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* System preference indicator */
|
||||
.theme-auto-indicator {
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
document.head.insertAdjacentHTML('beforeend', styles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event handlers
|
||||
*/
|
||||
bindEventHandlers() {
|
||||
// Handle theme toggle buttons
|
||||
const themeToggleButtons = document.querySelectorAll('[data-theme-toggle]');
|
||||
themeToggleButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
this.toggleTheme();
|
||||
});
|
||||
});
|
||||
|
||||
// Handle theme selection
|
||||
const themeSelectors = document.querySelectorAll('[data-theme-select]');
|
||||
themeSelectors.forEach(selector => {
|
||||
selector.addEventListener('change', (e) => {
|
||||
this.setThemePreference(e.target.value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle theme change
|
||||
*/
|
||||
handleThemeChange(newTheme) {
|
||||
const oldTheme = this.currentTheme;
|
||||
this.currentTheme = newTheme;
|
||||
|
||||
// Update map tile layers
|
||||
this.updateMapTileLayers(newTheme);
|
||||
|
||||
// Update marker themes
|
||||
this.updateMarkerThemes(newTheme);
|
||||
|
||||
// Emit theme change event
|
||||
const event = new CustomEvent('themeChanged', {
|
||||
detail: {
|
||||
oldTheme,
|
||||
newTheme,
|
||||
isSystemPreference: this.getStoredPreference() === 'auto'
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
console.log(`Theme changed from ${oldTheme} to ${newTheme}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update map tile layers for theme
|
||||
*/
|
||||
updateMapTileLayers(theme) {
|
||||
this.mapInstances.forEach(mapInstance => {
|
||||
const currentTileLayer = this.tileLayers.get(mapInstance);
|
||||
|
||||
if (currentTileLayer) {
|
||||
mapInstance.removeLayer(currentTileLayer);
|
||||
}
|
||||
|
||||
// Create new tile layer for theme
|
||||
const tileUrl = this.options.tileProviders[theme].osm;
|
||||
const newTileLayer = L.tileLayer(tileUrl, {
|
||||
attribution: '© OpenStreetMap contributors' + (theme === 'dark' ? ', © CARTO' : ''),
|
||||
className: `map-tiles-${theme}`
|
||||
});
|
||||
|
||||
newTileLayer.addTo(mapInstance);
|
||||
this.tileLayers.set(mapInstance, newTileLayer);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update marker themes
|
||||
*/
|
||||
updateMarkerThemes(theme) {
|
||||
if (window.mapMarkers) {
|
||||
// Clear marker caches to force re-render with new theme
|
||||
window.mapMarkers.clearIconCache();
|
||||
window.mapMarkers.clearPopupCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between light and dark themes
|
||||
*/
|
||||
toggleTheme() {
|
||||
const newTheme = this.currentTheme === 'light' ? 'dark' : 'light';
|
||||
this.setTheme(newTheme);
|
||||
this.setStoredPreference(newTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set specific theme
|
||||
*/
|
||||
setTheme(theme) {
|
||||
if (!['light', 'dark'].includes(theme)) return;
|
||||
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Update theme toggle states
|
||||
this.updateThemeToggleStates(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set theme preference (light, dark, auto)
|
||||
*/
|
||||
setThemePreference(preference) {
|
||||
if (!['light', 'dark', 'auto'].includes(preference)) return;
|
||||
|
||||
this.setStoredPreference(preference);
|
||||
|
||||
if (preference === 'auto') {
|
||||
this.applySystemPreference();
|
||||
} else {
|
||||
this.setTheme(preference);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply system preference
|
||||
*/
|
||||
applySystemPreference() {
|
||||
if (this.mediaQuery) {
|
||||
const systemPrefersDark = this.mediaQuery.matches;
|
||||
this.setTheme(systemPrefersDark ? 'dark' : 'light');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply stored preference
|
||||
*/
|
||||
applyStoredPreference(preference) {
|
||||
if (preference === 'auto') {
|
||||
this.applySystemPreference();
|
||||
} else {
|
||||
this.setTheme(preference);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored theme preference
|
||||
*/
|
||||
getStoredPreference() {
|
||||
return localStorage.getItem(this.options.storageKey) || 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set stored theme preference
|
||||
*/
|
||||
setStoredPreference(preference) {
|
||||
localStorage.setItem(this.options.storageKey, preference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update theme toggle button states
|
||||
*/
|
||||
updateThemeToggleStates(theme) {
|
||||
const toggleButtons = document.querySelectorAll('[data-theme-toggle]');
|
||||
toggleButtons.forEach(button => {
|
||||
button.setAttribute('data-theme', theme);
|
||||
button.setAttribute('aria-label', `Switch to ${theme === 'light' ? 'dark' : 'light'} theme`);
|
||||
});
|
||||
|
||||
const themeSelectors = document.querySelectorAll('[data-theme-select]');
|
||||
themeSelectors.forEach(selector => {
|
||||
selector.value = this.getStoredPreference();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register map instance for theme management
|
||||
*/
|
||||
registerMapInstance(mapInstance) {
|
||||
this.mapInstances.add(mapInstance);
|
||||
|
||||
// Apply current theme immediately
|
||||
setTimeout(() => {
|
||||
this.updateMapTileLayers(this.currentTheme);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister map instance
|
||||
*/
|
||||
unregisterMapInstance(mapInstance) {
|
||||
this.mapInstances.delete(mapInstance);
|
||||
this.tileLayers.delete(mapInstance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create theme toggle button
|
||||
*/
|
||||
createThemeToggle() {
|
||||
const toggle = document.createElement('button');
|
||||
toggle.className = 'theme-toggle';
|
||||
toggle.setAttribute('data-theme-toggle', 'true');
|
||||
toggle.setAttribute('aria-label', 'Toggle theme');
|
||||
toggle.innerHTML = `
|
||||
<i class="theme-icon sun fas fa-sun"></i>
|
||||
<i class="theme-icon moon fas fa-moon"></i>
|
||||
`;
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
this.toggleTheme();
|
||||
});
|
||||
|
||||
return toggle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create theme selector dropdown
|
||||
*/
|
||||
createThemeSelector() {
|
||||
const selector = document.createElement('select');
|
||||
selector.className = 'theme-selector';
|
||||
selector.setAttribute('data-theme-select', 'true');
|
||||
selector.innerHTML = `
|
||||
<option value="auto">Auto</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
`;
|
||||
|
||||
selector.value = this.getStoredPreference();
|
||||
|
||||
selector.addEventListener('change', (e) => {
|
||||
this.setThemePreference(e.target.value);
|
||||
});
|
||||
|
||||
return selector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current theme
|
||||
*/
|
||||
getCurrentTheme() {
|
||||
return this.currentTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if dark mode is active
|
||||
*/
|
||||
isDarkMode() {
|
||||
return this.currentTheme === 'dark';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if system preference is supported
|
||||
*/
|
||||
isSystemPreferenceSupported() {
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').media !== 'not all';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system preference
|
||||
*/
|
||||
getSystemPreference() {
|
||||
if (this.isSystemPreferenceSupported() && this.mediaQuery) {
|
||||
return this.mediaQuery.matches ? 'dark' : 'light';
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add theme transition classes
|
||||
*/
|
||||
addThemeTransitions() {
|
||||
const elements = document.querySelectorAll('.filter-chip, .park-item, .search-result-item, .popup-btn');
|
||||
elements.forEach(element => {
|
||||
element.classList.add('theme-transition');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove theme transition classes
|
||||
*/
|
||||
removeThemeTransitions() {
|
||||
const elements = document.querySelectorAll('.theme-transition');
|
||||
elements.forEach(element => {
|
||||
element.classList.remove('theme-transition');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy dark mode instance
|
||||
*/
|
||||
destroy() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
|
||||
if (this.mediaQuery) {
|
||||
if (this.mediaQuery.removeEventListener) {
|
||||
this.mediaQuery.removeEventListener('change', this.applySystemPreference);
|
||||
} else {
|
||||
this.mediaQuery.removeListener(this.applySystemPreference);
|
||||
}
|
||||
}
|
||||
|
||||
this.mapInstances.clear();
|
||||
this.tileLayers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize dark mode support
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.darkModeMaps = new DarkModeMaps();
|
||||
|
||||
// Register existing map instances
|
||||
if (window.thrillwikiMap) {
|
||||
window.darkModeMaps.registerMapInstance(window.thrillwikiMap);
|
||||
}
|
||||
|
||||
// Add theme transitions
|
||||
window.darkModeMaps.addThemeTransitions();
|
||||
|
||||
// Add theme toggle to navigation if it doesn't exist
|
||||
const nav = document.querySelector('nav, .navbar, .header-nav');
|
||||
if (nav && !nav.querySelector('[data-theme-toggle]')) {
|
||||
const themeToggle = window.darkModeMaps.createThemeToggle();
|
||||
themeToggle.style.marginLeft = 'auto';
|
||||
nav.appendChild(themeToggle);
|
||||
}
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = DarkModeMaps;
|
||||
} else {
|
||||
window.DarkModeMaps = DarkModeMaps;
|
||||
}
|
||||
@@ -1,720 +0,0 @@
|
||||
/**
|
||||
* ThrillWiki Geolocation - User Location and "Near Me" Functionality
|
||||
*
|
||||
* This module handles browser geolocation API integration with privacy-conscious
|
||||
* permission handling, distance calculations, and "near me" functionality
|
||||
*/
|
||||
|
||||
class UserLocation {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 300000, // 5 minutes
|
||||
watchPosition: false,
|
||||
autoShowOnMap: true,
|
||||
showAccuracyCircle: true,
|
||||
enableCaching: true,
|
||||
cacheKey: 'thrillwiki_user_location',
|
||||
apiEndpoints: {
|
||||
nearby: '/api/map/nearby/',
|
||||
distance: '/api/map/distance/'
|
||||
},
|
||||
defaultRadius: 50, // miles
|
||||
maxRadius: 500,
|
||||
...options
|
||||
};
|
||||
|
||||
this.currentPosition = null;
|
||||
this.watchId = null;
|
||||
this.mapInstance = null;
|
||||
this.locationMarker = null;
|
||||
this.accuracyCircle = null;
|
||||
this.permissionState = 'unknown';
|
||||
this.lastLocationTime = null;
|
||||
|
||||
// Event handlers
|
||||
this.eventHandlers = {
|
||||
locationFound: [],
|
||||
locationError: [],
|
||||
permissionChanged: []
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the geolocation component
|
||||
*/
|
||||
init() {
|
||||
this.checkGeolocationSupport();
|
||||
this.loadCachedLocation();
|
||||
this.setupLocationButtons();
|
||||
this.checkPermissionState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if geolocation is supported by the browser
|
||||
*/
|
||||
checkGeolocationSupport() {
|
||||
if (!navigator.geolocation) {
|
||||
console.warn('Geolocation is not supported by this browser');
|
||||
this.hideLocationButtons();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup location-related buttons and controls
|
||||
*/
|
||||
setupLocationButtons() {
|
||||
// Find all "locate me" buttons
|
||||
const locateButtons = document.querySelectorAll('[data-action="locate-user"], .locate-user-btn');
|
||||
|
||||
locateButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.requestLocation();
|
||||
});
|
||||
});
|
||||
|
||||
// Find "near me" buttons
|
||||
const nearMeButtons = document.querySelectorAll('[data-action="near-me"], .near-me-btn');
|
||||
|
||||
nearMeButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.showNearbyLocations();
|
||||
});
|
||||
});
|
||||
|
||||
// Distance calculator buttons
|
||||
const distanceButtons = document.querySelectorAll('[data-action="calculate-distance"]');
|
||||
|
||||
distanceButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const targetLat = parseFloat(button.dataset.lat);
|
||||
const targetLng = parseFloat(button.dataset.lng);
|
||||
this.calculateDistance(targetLat, targetLng);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide location buttons when geolocation is not supported
|
||||
*/
|
||||
hideLocationButtons() {
|
||||
const locationElements = document.querySelectorAll('.geolocation-feature');
|
||||
locationElements.forEach(el => {
|
||||
el.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check current permission state
|
||||
*/
|
||||
async checkPermissionState() {
|
||||
if ('permissions' in navigator) {
|
||||
try {
|
||||
const permission = await navigator.permissions.query({ name: 'geolocation' });
|
||||
this.permissionState = permission.state;
|
||||
this.updateLocationButtonStates();
|
||||
|
||||
// Listen for permission changes
|
||||
permission.addEventListener('change', () => {
|
||||
this.permissionState = permission.state;
|
||||
this.updateLocationButtonStates();
|
||||
this.triggerEvent('permissionChanged', this.permissionState);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Could not check geolocation permission:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update location button states based on permission
|
||||
*/
|
||||
updateLocationButtonStates() {
|
||||
const locateButtons = document.querySelectorAll('[data-action="locate-user"], .locate-user-btn');
|
||||
|
||||
locateButtons.forEach(button => {
|
||||
const icon = button.querySelector('i') || button;
|
||||
|
||||
switch (this.permissionState) {
|
||||
case 'granted':
|
||||
button.disabled = false;
|
||||
button.title = 'Find my location';
|
||||
icon.className = 'fas fa-crosshairs';
|
||||
break;
|
||||
case 'denied':
|
||||
button.disabled = true;
|
||||
button.title = 'Location access denied';
|
||||
icon.className = 'fas fa-times-circle';
|
||||
break;
|
||||
case 'prompt':
|
||||
default:
|
||||
button.disabled = false;
|
||||
button.title = 'Find my location (permission required)';
|
||||
icon.className = 'fas fa-crosshairs';
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request user location
|
||||
*/
|
||||
requestLocation(options = {}) {
|
||||
if (!navigator.geolocation) {
|
||||
this.handleLocationError(new Error('Geolocation not supported'));
|
||||
return;
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
...this.options,
|
||||
...options
|
||||
};
|
||||
|
||||
// Show loading state
|
||||
this.setLocationButtonLoading(true);
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => this.handleLocationSuccess(position),
|
||||
(error) => this.handleLocationError(error),
|
||||
{
|
||||
enableHighAccuracy: requestOptions.enableHighAccuracy,
|
||||
timeout: requestOptions.timeout,
|
||||
maximumAge: requestOptions.maximumAge
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching user position
|
||||
*/
|
||||
startWatching() {
|
||||
if (!navigator.geolocation || this.watchId) return;
|
||||
|
||||
this.watchId = navigator.geolocation.watchPosition(
|
||||
(position) => this.handleLocationSuccess(position),
|
||||
(error) => this.handleLocationError(error),
|
||||
{
|
||||
enableHighAccuracy: this.options.enableHighAccuracy,
|
||||
timeout: this.options.timeout,
|
||||
maximumAge: this.options.maximumAge
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching user position
|
||||
*/
|
||||
stopWatching() {
|
||||
if (this.watchId) {
|
||||
navigator.geolocation.clearWatch(this.watchId);
|
||||
this.watchId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle successful location acquisition
|
||||
*/
|
||||
handleLocationSuccess(position) {
|
||||
this.currentPosition = {
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy,
|
||||
timestamp: position.timestamp
|
||||
};
|
||||
|
||||
this.lastLocationTime = Date.now();
|
||||
|
||||
// Cache location
|
||||
if (this.options.enableCaching) {
|
||||
this.cacheLocation(this.currentPosition);
|
||||
}
|
||||
|
||||
// Show on map if enabled
|
||||
if (this.options.autoShowOnMap && this.mapInstance) {
|
||||
this.showLocationOnMap();
|
||||
}
|
||||
|
||||
// Update button states
|
||||
this.setLocationButtonLoading(false);
|
||||
this.updateLocationButtonStates();
|
||||
|
||||
// Trigger event
|
||||
this.triggerEvent('locationFound', this.currentPosition);
|
||||
|
||||
console.log('Location found:', this.currentPosition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle location errors
|
||||
*/
|
||||
handleLocationError(error) {
|
||||
this.setLocationButtonLoading(false);
|
||||
|
||||
let message = 'Unable to get your location';
|
||||
|
||||
switch (error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
message = 'Location access denied. Please enable location services.';
|
||||
this.permissionState = 'denied';
|
||||
break;
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
message = 'Location information is unavailable.';
|
||||
break;
|
||||
case error.TIMEOUT:
|
||||
message = 'Location request timed out.';
|
||||
break;
|
||||
default:
|
||||
message = 'An unknown error occurred while retrieving location.';
|
||||
break;
|
||||
}
|
||||
|
||||
this.showLocationMessage(message, 'error');
|
||||
this.updateLocationButtonStates();
|
||||
|
||||
// Trigger event
|
||||
this.triggerEvent('locationError', { error, message });
|
||||
|
||||
console.error('Location error:', error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show user location on map
|
||||
*/
|
||||
showLocationOnMap() {
|
||||
if (!this.mapInstance || !this.currentPosition) return;
|
||||
|
||||
const { lat, lng, accuracy } = this.currentPosition;
|
||||
|
||||
// Remove existing location marker and circle
|
||||
this.clearLocationDisplay();
|
||||
|
||||
// Add location marker
|
||||
this.locationMarker = L.marker([lat, lng], {
|
||||
icon: this.createUserLocationIcon()
|
||||
}).addTo(this.mapInstance);
|
||||
|
||||
this.locationMarker.bindPopup(`
|
||||
<div class="user-location-popup">
|
||||
<h4><i class="fas fa-map-marker-alt"></i> Your Location</h4>
|
||||
<p class="accuracy">Accuracy: ±${Math.round(accuracy)}m</p>
|
||||
<div class="location-actions">
|
||||
<button onclick="userLocation.showNearbyLocations()" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-search"></i> Find Nearby Parks
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Add accuracy circle if enabled and accuracy is reasonable
|
||||
if (this.options.showAccuracyCircle && accuracy < 1000) {
|
||||
this.accuracyCircle = L.circle([lat, lng], {
|
||||
radius: accuracy,
|
||||
fillColor: '#3388ff',
|
||||
fillOpacity: 0.2,
|
||||
color: '#3388ff',
|
||||
weight: 2,
|
||||
opacity: 0.5
|
||||
}).addTo(this.mapInstance);
|
||||
}
|
||||
|
||||
// Center map on user location
|
||||
this.mapInstance.setView([lat, lng], 13);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom icon for user location
|
||||
*/
|
||||
createUserLocationIcon() {
|
||||
return L.divIcon({
|
||||
className: 'user-location-marker',
|
||||
html: `
|
||||
<div class="user-location-inner">
|
||||
<i class="fas fa-crosshairs"></i>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear location display from map
|
||||
*/
|
||||
clearLocationDisplay() {
|
||||
if (this.locationMarker && this.mapInstance) {
|
||||
this.mapInstance.removeLayer(this.locationMarker);
|
||||
this.locationMarker = null;
|
||||
}
|
||||
|
||||
if (this.accuracyCircle && this.mapInstance) {
|
||||
this.mapInstance.removeLayer(this.accuracyCircle);
|
||||
this.accuracyCircle = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show nearby locations
|
||||
*/
|
||||
async showNearbyLocations(radius = null) {
|
||||
if (!this.currentPosition) {
|
||||
this.requestLocation();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const searchRadius = radius || this.options.defaultRadius;
|
||||
const { lat, lng } = this.currentPosition;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
radius: searchRadius,
|
||||
unit: 'miles'
|
||||
});
|
||||
|
||||
const response = await fetch(`${this.options.apiEndpoints.nearby}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.displayNearbyResults(data.data);
|
||||
} else {
|
||||
this.showLocationMessage('No nearby locations found', 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to find nearby locations:', error);
|
||||
this.showLocationMessage('Failed to find nearby locations', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display nearby search results
|
||||
*/
|
||||
displayNearbyResults(results) {
|
||||
// Find or create results container
|
||||
let resultsContainer = document.getElementById('nearby-results');
|
||||
|
||||
if (!resultsContainer) {
|
||||
resultsContainer = document.createElement('div');
|
||||
resultsContainer.id = 'nearby-results';
|
||||
resultsContainer.className = 'nearby-results-container';
|
||||
|
||||
// Try to insert after a logical element
|
||||
const mapContainer = document.getElementById('map-container');
|
||||
if (mapContainer && mapContainer.parentNode) {
|
||||
mapContainer.parentNode.insertBefore(resultsContainer, mapContainer.nextSibling);
|
||||
} else {
|
||||
document.body.appendChild(resultsContainer);
|
||||
}
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div class="nearby-results">
|
||||
<h3 class="results-title">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
Nearby Parks (${results.length} found)
|
||||
</h3>
|
||||
<div class="results-list">
|
||||
${results.map(location => `
|
||||
<div class="nearby-item">
|
||||
<div class="location-info">
|
||||
<h4 class="location-name">${location.name}</h4>
|
||||
<p class="location-address">${location.formatted_location || ''}</p>
|
||||
<p class="location-distance">
|
||||
<i class="fas fa-route"></i>
|
||||
${location.distance} away
|
||||
</p>
|
||||
</div>
|
||||
<div class="location-actions">
|
||||
<button onclick="userLocation.centerOnLocation(${location.latitude}, ${location.longitude})"
|
||||
class="btn btn-outline btn-sm">
|
||||
<i class="fas fa-map"></i> Show on Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
resultsContainer.innerHTML = html;
|
||||
|
||||
// Scroll to results
|
||||
resultsContainer.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance to a specific location
|
||||
*/
|
||||
async calculateDistance(targetLat, targetLng) {
|
||||
if (!this.currentPosition) {
|
||||
this.showLocationMessage('Please enable location services first', 'warning');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { lat, lng } = this.currentPosition;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
from_lat: lat,
|
||||
from_lng: lng,
|
||||
to_lat: targetLat,
|
||||
to_lng: targetLng
|
||||
});
|
||||
|
||||
const response = await fetch(`${this.options.apiEndpoints.distance}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
return data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to calculate distance:', error);
|
||||
}
|
||||
|
||||
// Fallback to Haversine formula
|
||||
return this.calculateHaversineDistance(
|
||||
this.currentPosition.lat,
|
||||
this.currentPosition.lng,
|
||||
targetLat,
|
||||
targetLng
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance using Haversine formula
|
||||
*/
|
||||
calculateHaversineDistance(lat1, lng1, lat2, lng2) {
|
||||
const R = 3959; // Earth's radius in miles
|
||||
const dLat = this.toRadians(lat2 - lat1);
|
||||
const dLng = this.toRadians(lng2 - lng1);
|
||||
|
||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) *
|
||||
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
const distance = R * c;
|
||||
|
||||
return {
|
||||
distance: Math.round(distance * 10) / 10,
|
||||
unit: 'miles'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert degrees to radians
|
||||
*/
|
||||
toRadians(degrees) {
|
||||
return degrees * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Center map on specific location
|
||||
*/
|
||||
centerOnLocation(lat, lng, zoom = 15) {
|
||||
if (this.mapInstance) {
|
||||
this.mapInstance.setView([lat, lng], zoom);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache user location
|
||||
*/
|
||||
cacheLocation(position) {
|
||||
try {
|
||||
const cacheData = {
|
||||
position: position,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem(this.options.cacheKey, JSON.stringify(cacheData));
|
||||
} catch (error) {
|
||||
console.warn('Failed to cache location:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cached location
|
||||
*/
|
||||
loadCachedLocation() {
|
||||
if (!this.options.enableCaching) return null;
|
||||
|
||||
try {
|
||||
const cached = localStorage.getItem(this.options.cacheKey);
|
||||
if (!cached) return null;
|
||||
|
||||
const cacheData = JSON.parse(cached);
|
||||
const age = Date.now() - cacheData.timestamp;
|
||||
|
||||
// Check if cache is still valid (5 minutes)
|
||||
if (age < this.options.maximumAge) {
|
||||
this.currentPosition = cacheData.position;
|
||||
this.lastLocationTime = cacheData.timestamp;
|
||||
return this.currentPosition;
|
||||
} else {
|
||||
// Remove expired cache
|
||||
localStorage.removeItem(this.options.cacheKey);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load cached location:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set loading state for location buttons
|
||||
*/
|
||||
setLocationButtonLoading(loading) {
|
||||
const locateButtons = document.querySelectorAll('[data-action="locate-user"], .locate-user-btn');
|
||||
|
||||
locateButtons.forEach(button => {
|
||||
const icon = button.querySelector('i') || button;
|
||||
|
||||
if (loading) {
|
||||
button.disabled = true;
|
||||
icon.className = 'fas fa-spinner fa-spin';
|
||||
} else {
|
||||
button.disabled = false;
|
||||
// Icon will be updated by updateLocationButtonStates
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show location-related message
|
||||
*/
|
||||
showLocationMessage(message, type = 'info') {
|
||||
// Create or update message element
|
||||
let messageEl = document.getElementById('location-message');
|
||||
|
||||
if (!messageEl) {
|
||||
messageEl = document.createElement('div');
|
||||
messageEl.id = 'location-message';
|
||||
messageEl.className = 'location-message';
|
||||
|
||||
// Insert at top of page or after header
|
||||
const header = document.querySelector('header, .header');
|
||||
if (header) {
|
||||
header.parentNode.insertBefore(messageEl, header.nextSibling);
|
||||
} else {
|
||||
document.body.insertBefore(messageEl, document.body.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
messageEl.textContent = message;
|
||||
messageEl.className = `location-message location-message-${type}`;
|
||||
messageEl.style.display = 'block';
|
||||
|
||||
// Auto-hide after delay
|
||||
setTimeout(() => {
|
||||
if (messageEl.parentNode) {
|
||||
messageEl.style.display = 'none';
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a map instance
|
||||
*/
|
||||
connectToMap(mapInstance) {
|
||||
this.mapInstance = mapInstance;
|
||||
|
||||
// Show cached location on map if available
|
||||
if (this.currentPosition && this.options.autoShowOnMap) {
|
||||
this.showLocationOnMap();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current position
|
||||
*/
|
||||
getCurrentPosition() {
|
||||
return this.currentPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if location is available
|
||||
*/
|
||||
hasLocation() {
|
||||
return this.currentPosition !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if location is recent
|
||||
*/
|
||||
isLocationRecent(maxAge = 300000) { // 5 minutes default
|
||||
if (!this.lastLocationTime) return false;
|
||||
return (Date.now() - this.lastLocationTime) < maxAge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add event listener
|
||||
*/
|
||||
on(event, handler) {
|
||||
if (!this.eventHandlers[event]) {
|
||||
this.eventHandlers[event] = [];
|
||||
}
|
||||
this.eventHandlers[event].push(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove event listener
|
||||
*/
|
||||
off(event, handler) {
|
||||
if (this.eventHandlers[event]) {
|
||||
const index = this.eventHandlers[event].indexOf(handler);
|
||||
if (index > -1) {
|
||||
this.eventHandlers[event].splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger event
|
||||
*/
|
||||
triggerEvent(event, data) {
|
||||
if (this.eventHandlers[event]) {
|
||||
this.eventHandlers[event].forEach(handler => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error(`Error in ${event} handler:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the geolocation instance
|
||||
*/
|
||||
destroy() {
|
||||
this.stopWatching();
|
||||
this.clearLocationDisplay();
|
||||
this.eventHandlers = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize user location
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.userLocation = new UserLocation();
|
||||
|
||||
// Connect to map instance if available
|
||||
if (window.thrillwikiMap) {
|
||||
window.userLocation.connectToMap(window.thrillwikiMap);
|
||||
}
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = UserLocation;
|
||||
} else {
|
||||
window.UserLocation = UserLocation;
|
||||
}
|
||||
@@ -1,725 +0,0 @@
|
||||
/**
|
||||
* ThrillWiki HTMX Maps Integration - Dynamic Map Updates via HTMX
|
||||
*
|
||||
* This module handles HTMX events for map updates, manages loading states
|
||||
* during API calls, updates map content based on HTMX responses, and provides
|
||||
* error handling for failed requests
|
||||
*/
|
||||
|
||||
class HTMXMapIntegration {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
mapInstance: null,
|
||||
filterInstance: null,
|
||||
defaultTarget: '#map-container',
|
||||
loadingClass: 'htmx-loading',
|
||||
errorClass: 'htmx-error',
|
||||
successClass: 'htmx-success',
|
||||
loadingTimeout: 30000, // 30 seconds
|
||||
retryAttempts: 3,
|
||||
retryDelay: 1000,
|
||||
...options
|
||||
};
|
||||
|
||||
this.loadingElements = new Set();
|
||||
this.activeRequests = new Map();
|
||||
this.requestQueue = [];
|
||||
this.retryCount = new Map();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize HTMX integration
|
||||
*/
|
||||
init() {
|
||||
if (typeof htmx === 'undefined') {
|
||||
console.warn('HTMX not found, map integration disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setupEventHandlers();
|
||||
this.setupCustomEvents();
|
||||
this.setupErrorHandling();
|
||||
this.enhanceExistingElements();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup HTMX event handlers
|
||||
*/
|
||||
setupEventHandlers() {
|
||||
// Before request - show loading states
|
||||
document.addEventListener('htmx:beforeRequest', (e) => {
|
||||
this.handleBeforeRequest(e);
|
||||
});
|
||||
|
||||
// After request - handle response and update maps
|
||||
document.addEventListener('htmx:afterRequest', (e) => {
|
||||
this.handleAfterRequest(e);
|
||||
});
|
||||
|
||||
// Response error - handle failed requests
|
||||
document.addEventListener('htmx:responseError', (e) => {
|
||||
this.handleResponseError(e);
|
||||
});
|
||||
|
||||
// Send error - handle network errors
|
||||
document.addEventListener('htmx:sendError', (e) => {
|
||||
this.handleSendError(e);
|
||||
});
|
||||
|
||||
// Timeout - handle request timeouts
|
||||
document.addEventListener('htmx:timeout', (e) => {
|
||||
this.handleTimeout(e);
|
||||
});
|
||||
|
||||
// Before swap - prepare for content updates
|
||||
document.addEventListener('htmx:beforeSwap', (e) => {
|
||||
this.handleBeforeSwap(e);
|
||||
});
|
||||
|
||||
// After swap - update maps with new content
|
||||
document.addEventListener('htmx:afterSwap', (e) => {
|
||||
this.handleAfterSwap(e);
|
||||
});
|
||||
|
||||
// Config request - modify requests before sending
|
||||
document.addEventListener('htmx:configRequest', (e) => {
|
||||
this.handleConfigRequest(e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup custom map-specific events
|
||||
*/
|
||||
setupCustomEvents() {
|
||||
// Custom event for map data updates
|
||||
document.addEventListener('map:dataUpdate', (e) => {
|
||||
this.handleMapDataUpdate(e);
|
||||
});
|
||||
|
||||
// Custom event for filter changes
|
||||
document.addEventListener('filter:changed', (e) => {
|
||||
this.handleFilterChange(e);
|
||||
});
|
||||
|
||||
// Custom event for search updates
|
||||
document.addEventListener('search:results', (e) => {
|
||||
this.handleSearchResults(e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup global error handling
|
||||
*/
|
||||
setupErrorHandling() {
|
||||
// Global error handler
|
||||
window.addEventListener('error', (e) => {
|
||||
if (e.filename && e.filename.includes('htmx')) {
|
||||
console.error('HTMX error:', e.error);
|
||||
this.showErrorMessage('An error occurred while updating the map');
|
||||
}
|
||||
});
|
||||
|
||||
// Unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
if (e.reason && e.reason.toString().includes('htmx')) {
|
||||
console.error('HTMX promise rejection:', e.reason);
|
||||
this.showErrorMessage('Failed to complete map request');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance existing elements with HTMX map functionality
|
||||
*/
|
||||
enhanceExistingElements() {
|
||||
// Add map-specific attributes to filter forms
|
||||
const filterForms = document.querySelectorAll('[data-map-filter]');
|
||||
filterForms.forEach(form => {
|
||||
if (!form.hasAttribute('hx-get')) {
|
||||
form.setAttribute('hx-get', form.getAttribute('data-map-filter'));
|
||||
form.setAttribute('hx-trigger', 'change, submit');
|
||||
form.setAttribute('hx-target', '#map-container');
|
||||
form.setAttribute('hx-swap', 'none');
|
||||
}
|
||||
});
|
||||
|
||||
// Add map update attributes to search inputs
|
||||
const searchInputs = document.querySelectorAll('[data-map-search]');
|
||||
searchInputs.forEach(input => {
|
||||
if (!input.hasAttribute('hx-get')) {
|
||||
input.setAttribute('hx-get', input.getAttribute('data-map-search'));
|
||||
input.setAttribute('hx-trigger', 'input changed delay:500ms');
|
||||
input.setAttribute('hx-target', '#search-results');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle before request event
|
||||
*/
|
||||
handleBeforeRequest(e) {
|
||||
const element = e.target;
|
||||
const requestId = this.generateRequestId();
|
||||
|
||||
// Store request information
|
||||
this.activeRequests.set(requestId, {
|
||||
element: element,
|
||||
startTime: Date.now(),
|
||||
url: e.detail.requestConfig.path
|
||||
});
|
||||
|
||||
// Show loading state
|
||||
this.showLoadingState(element, true);
|
||||
|
||||
// Add request ID to detail for tracking
|
||||
e.detail.requestId = requestId;
|
||||
|
||||
// Set timeout
|
||||
setTimeout(() => {
|
||||
if (this.activeRequests.has(requestId)) {
|
||||
this.handleTimeout({ detail: { requestId } });
|
||||
}
|
||||
}, this.options.loadingTimeout);
|
||||
|
||||
console.log('HTMX request started:', e.detail.requestConfig.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle after request event
|
||||
*/
|
||||
handleAfterRequest(e) {
|
||||
const requestId = e.detail.requestId;
|
||||
const request = this.activeRequests.get(requestId);
|
||||
|
||||
if (request) {
|
||||
const duration = Date.now() - request.startTime;
|
||||
console.log(`HTMX request completed in ${duration}ms:`, request.url);
|
||||
|
||||
this.activeRequests.delete(requestId);
|
||||
this.showLoadingState(request.element, false);
|
||||
}
|
||||
|
||||
if (e.detail.successful) {
|
||||
this.handleSuccessfulResponse(e);
|
||||
} else {
|
||||
this.handleFailedResponse(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle successful response
|
||||
*/
|
||||
handleSuccessfulResponse(e) {
|
||||
const element = e.target;
|
||||
|
||||
// Add success class temporarily
|
||||
element.classList.add(this.options.successClass);
|
||||
setTimeout(() => {
|
||||
element.classList.remove(this.options.successClass);
|
||||
}, 2000);
|
||||
|
||||
// Reset retry count
|
||||
this.retryCount.delete(element);
|
||||
|
||||
// Check if this is a map-related request
|
||||
if (this.isMapRequest(e)) {
|
||||
this.updateMapFromResponse(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle failed response
|
||||
*/
|
||||
handleFailedResponse(e) {
|
||||
const element = e.target;
|
||||
|
||||
// Add error class
|
||||
element.classList.add(this.options.errorClass);
|
||||
setTimeout(() => {
|
||||
element.classList.remove(this.options.errorClass);
|
||||
}, 5000);
|
||||
|
||||
// Check if we should retry
|
||||
if (this.shouldRetry(element)) {
|
||||
this.scheduleRetry(element, e.detail);
|
||||
} else {
|
||||
this.showErrorMessage('Failed to update map data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle response error
|
||||
*/
|
||||
handleResponseError(e) {
|
||||
console.error('HTMX response error:', e.detail);
|
||||
|
||||
const element = e.target;
|
||||
const status = e.detail.xhr.status;
|
||||
|
||||
let message = 'An error occurred while updating the map';
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
message = 'Invalid request parameters';
|
||||
break;
|
||||
case 401:
|
||||
message = 'Authentication required';
|
||||
break;
|
||||
case 403:
|
||||
message = 'Access denied';
|
||||
break;
|
||||
case 404:
|
||||
message = 'Map data not found';
|
||||
break;
|
||||
case 429:
|
||||
message = 'Too many requests. Please wait a moment.';
|
||||
break;
|
||||
case 500:
|
||||
message = 'Server error. Please try again later.';
|
||||
break;
|
||||
}
|
||||
|
||||
this.showErrorMessage(message);
|
||||
this.showLoadingState(element, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle send error
|
||||
*/
|
||||
handleSendError(e) {
|
||||
console.error('HTMX send error:', e.detail);
|
||||
this.showErrorMessage('Network error. Please check your connection.');
|
||||
this.showLoadingState(e.target, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle timeout
|
||||
*/
|
||||
handleTimeout(e) {
|
||||
console.warn('HTMX request timeout');
|
||||
|
||||
if (e.detail.requestId) {
|
||||
const request = this.activeRequests.get(e.detail.requestId);
|
||||
if (request) {
|
||||
this.showLoadingState(request.element, false);
|
||||
this.activeRequests.delete(e.detail.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
this.showErrorMessage('Request timed out. Please try again.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle before swap
|
||||
*/
|
||||
handleBeforeSwap(e) {
|
||||
// Prepare map for content update
|
||||
if (this.isMapRequest(e)) {
|
||||
console.log('Preparing map for content swap');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle after swap
|
||||
*/
|
||||
handleAfterSwap(e) {
|
||||
// Re-initialize any new HTMX elements
|
||||
this.enhanceExistingElements();
|
||||
|
||||
// Update maps if needed
|
||||
if (this.isMapRequest(e)) {
|
||||
this.reinitializeMapComponents();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle config request
|
||||
*/
|
||||
handleConfigRequest(e) {
|
||||
const config = e.detail;
|
||||
|
||||
// Add CSRF token if available
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
if (csrfToken && (config.verb === 'post' || config.verb === 'put' || config.verb === 'patch')) {
|
||||
config.headers['X-CSRFToken'] = csrfToken.value;
|
||||
}
|
||||
|
||||
// Add map-specific headers
|
||||
if (this.isMapRequest(e)) {
|
||||
config.headers['X-Map-Request'] = 'true';
|
||||
|
||||
// Add current map bounds if available
|
||||
if (this.options.mapInstance) {
|
||||
const bounds = this.options.mapInstance.getBounds();
|
||||
if (bounds) {
|
||||
config.headers['X-Map-Bounds'] = JSON.stringify({
|
||||
north: bounds.getNorth(),
|
||||
south: bounds.getSouth(),
|
||||
east: bounds.getEast(),
|
||||
west: bounds.getWest(),
|
||||
zoom: this.options.mapInstance.getZoom()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle map data updates
|
||||
*/
|
||||
handleMapDataUpdate(e) {
|
||||
if (this.options.mapInstance) {
|
||||
const data = e.detail;
|
||||
this.options.mapInstance.updateMarkers(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle filter changes
|
||||
*/
|
||||
handleFilterChange(e) {
|
||||
if (this.options.filterInstance) {
|
||||
const filters = e.detail;
|
||||
|
||||
// Trigger HTMX request for filter update
|
||||
const filterForm = document.getElementById('map-filters');
|
||||
if (filterForm && filterForm.hasAttribute('hx-get')) {
|
||||
htmx.trigger(filterForm, 'change');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search results
|
||||
*/
|
||||
handleSearchResults(e) {
|
||||
const results = e.detail;
|
||||
|
||||
// Update map with search results if applicable
|
||||
if (results.locations && this.options.mapInstance) {
|
||||
this.options.mapInstance.updateMarkers({ locations: results.locations });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide loading state
|
||||
*/
|
||||
showLoadingState(element, show) {
|
||||
if (show) {
|
||||
element.classList.add(this.options.loadingClass);
|
||||
this.loadingElements.add(element);
|
||||
|
||||
// Show loading indicators
|
||||
const indicators = element.querySelectorAll('.htmx-indicator');
|
||||
indicators.forEach(indicator => {
|
||||
indicator.style.display = 'block';
|
||||
});
|
||||
|
||||
// Disable form elements
|
||||
const inputs = element.querySelectorAll('input, button, select');
|
||||
inputs.forEach(input => {
|
||||
input.disabled = true;
|
||||
});
|
||||
} else {
|
||||
element.classList.remove(this.options.loadingClass);
|
||||
this.loadingElements.delete(element);
|
||||
|
||||
// Hide loading indicators
|
||||
const indicators = element.querySelectorAll('.htmx-indicator');
|
||||
indicators.forEach(indicator => {
|
||||
indicator.style.display = 'none';
|
||||
});
|
||||
|
||||
// Re-enable form elements
|
||||
const inputs = element.querySelectorAll('input, button, select');
|
||||
inputs.forEach(input => {
|
||||
input.disabled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is map-related
|
||||
*/
|
||||
isMapRequest(e) {
|
||||
const element = e.target;
|
||||
const url = e.detail.requestConfig ? e.detail.requestConfig.path : '';
|
||||
|
||||
return element.hasAttribute('data-map-filter') ||
|
||||
element.hasAttribute('data-map-search') ||
|
||||
element.closest('[data-map-target]') ||
|
||||
url.includes('/api/map/') ||
|
||||
url.includes('/maps/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update map from HTMX response
|
||||
*/
|
||||
updateMapFromResponse(e) {
|
||||
if (!this.options.mapInstance) return;
|
||||
|
||||
try {
|
||||
// Try to extract map data from response
|
||||
const responseText = e.detail.xhr.responseText;
|
||||
|
||||
// If response is JSON, update map directly
|
||||
try {
|
||||
const data = JSON.parse(responseText);
|
||||
if (data.status === 'success' && data.data) {
|
||||
this.options.mapInstance.updateMarkers(data.data);
|
||||
}
|
||||
} catch (jsonError) {
|
||||
// If not JSON, look for data attributes in HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = responseText;
|
||||
|
||||
const mapData = tempDiv.querySelector('[data-map-data]');
|
||||
if (mapData) {
|
||||
const data = JSON.parse(mapData.getAttribute('data-map-data'));
|
||||
this.options.mapInstance.updateMarkers(data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update map from response:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element should be retried
|
||||
*/
|
||||
shouldRetry(element) {
|
||||
const retryCount = this.retryCount.get(element) || 0;
|
||||
return retryCount < this.options.retryAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule retry for failed request
|
||||
*/
|
||||
scheduleRetry(element, detail) {
|
||||
const retryCount = (this.retryCount.get(element) || 0) + 1;
|
||||
this.retryCount.set(element, retryCount);
|
||||
|
||||
const delay = this.options.retryDelay * Math.pow(2, retryCount - 1); // Exponential backoff
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(`Retrying HTMX request (attempt ${retryCount})`);
|
||||
htmx.trigger(element, 'retry');
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message to user
|
||||
*/
|
||||
showErrorMessage(message) {
|
||||
// Create or update error message element
|
||||
let errorEl = document.getElementById('htmx-error-message');
|
||||
|
||||
if (!errorEl) {
|
||||
errorEl = document.createElement('div');
|
||||
errorEl.id = 'htmx-error-message';
|
||||
errorEl.className = 'htmx-error-message';
|
||||
|
||||
// Insert at top of page
|
||||
document.body.insertBefore(errorEl, document.body.firstChild);
|
||||
}
|
||||
|
||||
errorEl.innerHTML = `
|
||||
<div class="error-content">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span>${message}</span>
|
||||
<button onclick="this.parentElement.parentElement.remove()" class="error-close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
errorEl.style.display = 'block';
|
||||
|
||||
// Auto-hide after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (errorEl.parentNode) {
|
||||
errorEl.remove();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinitialize map components after content swap
|
||||
*/
|
||||
reinitializeMapComponents() {
|
||||
// Reinitialize filter components
|
||||
if (this.options.filterInstance) {
|
||||
this.options.filterInstance.init();
|
||||
}
|
||||
|
||||
// Reinitialize any new map containers
|
||||
const newMapContainers = document.querySelectorAll('[data-map="auto"]:not([data-initialized])');
|
||||
newMapContainers.forEach(container => {
|
||||
container.setAttribute('data-initialized', 'true');
|
||||
// Initialize new map instance if needed
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique request ID
|
||||
*/
|
||||
generateRequestId() {
|
||||
return `htmx-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to map instance
|
||||
*/
|
||||
connectToMap(mapInstance) {
|
||||
this.options.mapInstance = mapInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to filter instance
|
||||
*/
|
||||
connectToFilter(filterInstance) {
|
||||
this.options.filterInstance = filterInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active request count
|
||||
*/
|
||||
getActiveRequestCount() {
|
||||
return this.activeRequests.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all active requests
|
||||
*/
|
||||
cancelAllRequests() {
|
||||
this.activeRequests.forEach((request, id) => {
|
||||
this.showLoadingState(request.element, false);
|
||||
});
|
||||
this.activeRequests.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get loading elements
|
||||
*/
|
||||
getLoadingElements() {
|
||||
return Array.from(this.loadingElements);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize HTMX integration
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.htmxMapIntegration = new HTMXMapIntegration();
|
||||
|
||||
// Connect to existing instances
|
||||
if (window.thrillwikiMap) {
|
||||
window.htmxMapIntegration.connectToMap(window.thrillwikiMap);
|
||||
}
|
||||
|
||||
if (window.mapFilters) {
|
||||
window.htmxMapIntegration.connectToFilter(window.mapFilters);
|
||||
}
|
||||
});
|
||||
|
||||
// Add styles for HTMX integration
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (document.getElementById('htmx-map-styles')) return;
|
||||
|
||||
const styles = `
|
||||
<style id="htmx-map-styles">
|
||||
.htmx-loading {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.htmx-error {
|
||||
border-color: #EF4444;
|
||||
background-color: #FEE2E2;
|
||||
}
|
||||
|
||||
.htmx-success {
|
||||
border-color: #10B981;
|
||||
background-color: #D1FAE5;
|
||||
}
|
||||
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-error-message {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 10000;
|
||||
max-width: 400px;
|
||||
background: #FEE2E2;
|
||||
border: 1px solid #FCA5A5;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
animation: slideInRight 0.3s ease;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
color: #991B1B;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #991B1B;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.error-close:hover {
|
||||
color: #7F1D1D;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
.dark .htmx-error-message {
|
||||
background: #7F1D1D;
|
||||
border-color: #991B1B;
|
||||
}
|
||||
|
||||
.dark .error-content {
|
||||
color: #FCA5A5;
|
||||
}
|
||||
|
||||
.dark .error-close {
|
||||
color: #FCA5A5;
|
||||
}
|
||||
|
||||
.dark .error-close:hover {
|
||||
color: #F87171;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
document.head.insertAdjacentHTML('beforeend', styles);
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = HTMXMapIntegration;
|
||||
} else {
|
||||
window.HTMXMapIntegration = HTMXMapIntegration;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
function locationAutocomplete(field, filterParks = false) {
|
||||
return {
|
||||
query: '',
|
||||
suggestions: [],
|
||||
fetchSuggestions() {
|
||||
let url;
|
||||
const params = new URLSearchParams({
|
||||
q: this.query,
|
||||
filter_parks: filterParks
|
||||
});
|
||||
|
||||
switch (field) {
|
||||
case 'country':
|
||||
url = '/parks/ajax/countries/';
|
||||
break;
|
||||
case 'region':
|
||||
url = '/parks/ajax/regions/';
|
||||
// Add country parameter if we're fetching regions
|
||||
const countryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
|
||||
if (countryInput && countryInput.value) {
|
||||
params.append('country', countryInput.value);
|
||||
}
|
||||
break;
|
||||
case 'city':
|
||||
url = '/parks/ajax/cities/';
|
||||
// Add country and region parameters if we're fetching cities
|
||||
const regionInput = document.getElementById(filterParks ? 'region' : 'id_region_name');
|
||||
const cityCountryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
|
||||
if (regionInput && regionInput.value && cityCountryInput && cityCountryInput.value) {
|
||||
params.append('country', cityCountryInput.value);
|
||||
params.append('region', regionInput.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (url) {
|
||||
fetch(`${url}?${params}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
this.suggestions = data;
|
||||
});
|
||||
}
|
||||
},
|
||||
selectSuggestion(suggestion) {
|
||||
this.query = suggestion.name;
|
||||
this.suggestions = [];
|
||||
|
||||
// If this is a form field (not filter), update hidden fields
|
||||
if (!filterParks) {
|
||||
const hiddenField = document.getElementById(`id_${field}`);
|
||||
if (hiddenField) {
|
||||
hiddenField.value = suggestion.id;
|
||||
}
|
||||
|
||||
// Clear dependent fields when parent field changes
|
||||
if (field === 'country') {
|
||||
const regionInput = document.getElementById('id_region_name');
|
||||
const cityInput = document.getElementById('id_city_name');
|
||||
const regionHidden = document.getElementById('id_region');
|
||||
const cityHidden = document.getElementById('id_city');
|
||||
|
||||
if (regionInput) regionInput.value = '';
|
||||
if (cityInput) cityInput.value = '';
|
||||
if (regionHidden) regionHidden.value = '';
|
||||
if (cityHidden) cityHidden.value = '';
|
||||
} else if (field === 'region') {
|
||||
const cityInput = document.getElementById('id_city_name');
|
||||
const cityHidden = document.getElementById('id_city');
|
||||
|
||||
if (cityInput) cityInput.value = '';
|
||||
if (cityHidden) cityHidden.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger form submission for filters
|
||||
if (filterParks) {
|
||||
htmx.trigger('#park-filters', 'change');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const useLocationBtn = document.getElementById('use-my-location');
|
||||
const latInput = document.getElementById('lat-input');
|
||||
const lngInput = document.getElementById('lng-input');
|
||||
const locationInput = document.getElementById('location-input');
|
||||
|
||||
if (useLocationBtn && 'geolocation' in navigator) {
|
||||
useLocationBtn.addEventListener('click', function() {
|
||||
this.textContent = '📍 Getting location...';
|
||||
this.disabled = true;
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
function(position) {
|
||||
latInput.value = position.coords.latitude;
|
||||
lngInput.value = position.coords.longitude;
|
||||
locationInput.value = `${position.coords.latitude.toFixed(6)}, ${position.coords.longitude.toFixed(6)}`;
|
||||
useLocationBtn.textContent = '✅ Location set';
|
||||
setTimeout(() => {
|
||||
useLocationBtn.textContent = '📍 Use My Location';
|
||||
useLocationBtn.disabled = false;
|
||||
}, 2000);
|
||||
},
|
||||
function(error) {
|
||||
useLocationBtn.textContent = '❌ Location failed';
|
||||
console.error('Geolocation error:', error);
|
||||
setTimeout(() => {
|
||||
useLocationBtn.textContent = '📍 Use My Location';
|
||||
useLocationBtn.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
);
|
||||
});
|
||||
} else if (useLocationBtn) {
|
||||
useLocationBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Autocomplete for location search
|
||||
if (locationInput) {
|
||||
locationInput.addEventListener('input', function() {
|
||||
const query = this.value;
|
||||
if (query.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/search/location/suggestions/?q=${query}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// This is a simplified example. A more robust solution would use a library like Awesomplete or build a custom dropdown.
|
||||
console.log('Suggestions:', data.suggestions);
|
||||
})
|
||||
.catch(error => console.error('Error fetching suggestions:', error));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
// 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');
|
||||
if (icon) {
|
||||
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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,573 +0,0 @@
|
||||
/**
|
||||
* ThrillWiki Map Filters - Location Filtering Component
|
||||
*
|
||||
* This module handles filter panel interactions and updates maps via HTMX
|
||||
* Supports location type filtering, geographic filtering, and real-time search
|
||||
*/
|
||||
|
||||
class MapFilters {
|
||||
constructor(formId, options = {}) {
|
||||
this.formId = formId;
|
||||
this.options = {
|
||||
autoSubmit: true,
|
||||
searchDelay: 500,
|
||||
enableLocalStorage: true,
|
||||
storageKey: 'thrillwiki_map_filters',
|
||||
mapInstance: null,
|
||||
htmxTarget: '#map-container',
|
||||
htmxUrl: null,
|
||||
...options
|
||||
};
|
||||
|
||||
this.form = null;
|
||||
this.searchTimeout = null;
|
||||
this.currentFilters = {};
|
||||
this.filterChips = [];
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the filter component
|
||||
*/
|
||||
init() {
|
||||
this.form = document.getElementById(this.formId);
|
||||
if (!this.form) {
|
||||
console.error(`Filter form with ID '${this.formId}' not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setupFilterChips();
|
||||
this.bindEvents();
|
||||
this.loadSavedFilters();
|
||||
this.initializeFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup filter chip interactions
|
||||
*/
|
||||
setupFilterChips() {
|
||||
this.filterChips = this.form.querySelectorAll('.filter-chip, .filter-pill');
|
||||
|
||||
this.filterChips.forEach(chip => {
|
||||
const checkbox = chip.querySelector('input[type="checkbox"]');
|
||||
|
||||
if (checkbox) {
|
||||
// Set initial state
|
||||
this.updateChipState(chip, checkbox.checked);
|
||||
|
||||
// Bind click handler
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.toggleChip(chip, checkbox);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle filter chip state
|
||||
*/
|
||||
toggleChip(chip, checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
this.updateChipState(chip, checkbox.checked);
|
||||
|
||||
// Trigger change event
|
||||
this.handleFilterChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visual state of filter chip
|
||||
*/
|
||||
updateChipState(chip, isActive) {
|
||||
if (isActive) {
|
||||
chip.classList.add('active');
|
||||
chip.classList.remove('inactive');
|
||||
} else {
|
||||
chip.classList.remove('active');
|
||||
chip.classList.add('inactive');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event handlers
|
||||
*/
|
||||
bindEvents() {
|
||||
// Form submission
|
||||
this.form.addEventListener('submit', (e) => {
|
||||
if (this.options.autoSubmit) {
|
||||
e.preventDefault();
|
||||
this.submitFilters();
|
||||
}
|
||||
});
|
||||
|
||||
// Input changes (excluding search)
|
||||
this.form.addEventListener('change', (e) => {
|
||||
if (e.target.name !== 'q' && !e.target.closest('.no-auto-submit')) {
|
||||
this.handleFilterChange();
|
||||
}
|
||||
});
|
||||
|
||||
// Search input with debouncing
|
||||
const searchInput = this.form.querySelector('input[name="q"]');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => {
|
||||
this.handleSearchInput();
|
||||
});
|
||||
}
|
||||
|
||||
// Range inputs
|
||||
const rangeInputs = this.form.querySelectorAll('input[type="range"]');
|
||||
rangeInputs.forEach(input => {
|
||||
input.addEventListener('input', (e) => {
|
||||
this.updateRangeDisplay(e.target);
|
||||
});
|
||||
});
|
||||
|
||||
// Clear filters button
|
||||
const clearButton = this.form.querySelector('[data-action="clear-filters"]');
|
||||
if (clearButton) {
|
||||
clearButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.clearAllFilters();
|
||||
});
|
||||
}
|
||||
|
||||
// HTMX events
|
||||
if (typeof htmx !== 'undefined') {
|
||||
this.form.addEventListener('htmx:beforeRequest', () => {
|
||||
this.showLoadingState(true);
|
||||
});
|
||||
|
||||
this.form.addEventListener('htmx:afterRequest', (e) => {
|
||||
this.showLoadingState(false);
|
||||
if (e.detail.successful) {
|
||||
this.onFiltersApplied(this.getCurrentFilters());
|
||||
}
|
||||
});
|
||||
|
||||
this.form.addEventListener('htmx:responseError', (e) => {
|
||||
this.showLoadingState(false);
|
||||
this.handleError(e.detail);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search input with debouncing
|
||||
*/
|
||||
handleSearchInput() {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.handleFilterChange();
|
||||
}, this.options.searchDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle filter changes
|
||||
*/
|
||||
handleFilterChange() {
|
||||
const filters = this.getCurrentFilters();
|
||||
this.currentFilters = filters;
|
||||
|
||||
if (this.options.autoSubmit) {
|
||||
this.submitFilters();
|
||||
}
|
||||
|
||||
// Save filters to localStorage
|
||||
if (this.options.enableLocalStorage) {
|
||||
this.saveFilters(filters);
|
||||
}
|
||||
|
||||
// Update map if connected
|
||||
if (this.options.mapInstance && this.options.mapInstance.updateFilters) {
|
||||
this.options.mapInstance.updateFilters(filters);
|
||||
}
|
||||
|
||||
// Trigger custom event
|
||||
this.triggerFilterEvent('filterChange', filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit filters via HTMX or form submission
|
||||
*/
|
||||
submitFilters() {
|
||||
if (typeof htmx !== 'undefined' && this.options.htmxUrl) {
|
||||
// Use HTMX
|
||||
const formData = new FormData(this.form);
|
||||
const params = new URLSearchParams(formData);
|
||||
|
||||
htmx.ajax('GET', `${this.options.htmxUrl}?${params}`, {
|
||||
target: this.options.htmxTarget,
|
||||
swap: 'none'
|
||||
});
|
||||
} else {
|
||||
// Regular form submission
|
||||
this.form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current filter values
|
||||
*/
|
||||
getCurrentFilters() {
|
||||
const formData = new FormData(this.form);
|
||||
const filters = {};
|
||||
|
||||
for (let [key, value] of formData.entries()) {
|
||||
if (value.trim() === '') continue;
|
||||
|
||||
if (filters[key]) {
|
||||
if (Array.isArray(filters[key])) {
|
||||
filters[key].push(value);
|
||||
} else {
|
||||
filters[key] = [filters[key], value];
|
||||
}
|
||||
} else {
|
||||
filters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set filter values
|
||||
*/
|
||||
setFilters(filters) {
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
const elements = this.form.querySelectorAll(`[name="${key}"]`);
|
||||
|
||||
elements.forEach(element => {
|
||||
if (element.type === 'checkbox' || element.type === 'radio') {
|
||||
if (Array.isArray(value)) {
|
||||
element.checked = value.includes(element.value);
|
||||
} else {
|
||||
element.checked = element.value === value;
|
||||
}
|
||||
|
||||
// Update chip state if applicable
|
||||
const chip = element.closest('.filter-chip, .filter-pill');
|
||||
if (chip) {
|
||||
this.updateChipState(chip, element.checked);
|
||||
}
|
||||
} else {
|
||||
element.value = Array.isArray(value) ? value[0] : value;
|
||||
|
||||
// Update range display if applicable
|
||||
if (element.type === 'range') {
|
||||
this.updateRangeDisplay(element);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.currentFilters = filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all filters
|
||||
*/
|
||||
clearAllFilters() {
|
||||
// Reset form
|
||||
this.form.reset();
|
||||
|
||||
// Update all chip states
|
||||
this.filterChips.forEach(chip => {
|
||||
const checkbox = chip.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) {
|
||||
this.updateChipState(chip, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Update range displays
|
||||
const rangeInputs = this.form.querySelectorAll('input[type="range"]');
|
||||
rangeInputs.forEach(input => {
|
||||
this.updateRangeDisplay(input);
|
||||
});
|
||||
|
||||
// Clear saved filters
|
||||
if (this.options.enableLocalStorage) {
|
||||
localStorage.removeItem(this.options.storageKey);
|
||||
}
|
||||
|
||||
// Submit cleared filters
|
||||
this.handleFilterChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update range input display
|
||||
*/
|
||||
updateRangeDisplay(rangeInput) {
|
||||
const valueDisplay = document.getElementById(`${rangeInput.id}-value`) ||
|
||||
document.getElementById(`${rangeInput.name}-value`);
|
||||
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = rangeInput.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load saved filters from localStorage
|
||||
*/
|
||||
loadSavedFilters() {
|
||||
if (!this.options.enableLocalStorage) return;
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(this.options.storageKey);
|
||||
if (saved) {
|
||||
const filters = JSON.parse(saved);
|
||||
this.setFilters(filters);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load saved filters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save filters to localStorage
|
||||
*/
|
||||
saveFilters(filters) {
|
||||
if (!this.options.enableLocalStorage) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(this.options.storageKey, JSON.stringify(filters));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save filters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize filters from URL parameters or defaults
|
||||
*/
|
||||
initializeFilters() {
|
||||
// Check for URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlFilters = {};
|
||||
|
||||
for (let [key, value] of urlParams.entries()) {
|
||||
if (urlFilters[key]) {
|
||||
if (Array.isArray(urlFilters[key])) {
|
||||
urlFilters[key].push(value);
|
||||
} else {
|
||||
urlFilters[key] = [urlFilters[key], value];
|
||||
}
|
||||
} else {
|
||||
urlFilters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(urlFilters).length > 0) {
|
||||
this.setFilters(urlFilters);
|
||||
}
|
||||
|
||||
// Emit initial filter state
|
||||
this.triggerFilterEvent('filterInit', this.getCurrentFilters());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide loading state
|
||||
*/
|
||||
showLoadingState(show) {
|
||||
const loadingIndicators = this.form.querySelectorAll('.filter-loading, .htmx-indicator');
|
||||
loadingIndicators.forEach(indicator => {
|
||||
indicator.style.display = show ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Disable form during loading
|
||||
const inputs = this.form.querySelectorAll('input, select, button');
|
||||
inputs.forEach(input => {
|
||||
input.disabled = show;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle errors
|
||||
*/
|
||||
handleError(detail) {
|
||||
console.error('Filter request failed:', detail);
|
||||
|
||||
// Show user-friendly error message
|
||||
this.showMessage('Failed to apply filters. Please try again.', 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show message to user
|
||||
*/
|
||||
showMessage(message, type = 'info') {
|
||||
// Create or update message element
|
||||
let messageEl = this.form.querySelector('.filter-message');
|
||||
if (!messageEl) {
|
||||
messageEl = document.createElement('div');
|
||||
messageEl.className = 'filter-message';
|
||||
this.form.insertBefore(messageEl, this.form.firstChild);
|
||||
}
|
||||
|
||||
messageEl.textContent = message;
|
||||
messageEl.className = `filter-message filter-message-${type}`;
|
||||
|
||||
// Auto-hide after delay
|
||||
setTimeout(() => {
|
||||
if (messageEl.parentNode) {
|
||||
messageEl.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for when filters are successfully applied
|
||||
*/
|
||||
onFiltersApplied(filters) {
|
||||
this.triggerFilterEvent('filterApplied', filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger custom events
|
||||
*/
|
||||
triggerFilterEvent(eventName, data) {
|
||||
const event = new CustomEvent(eventName, {
|
||||
detail: data
|
||||
});
|
||||
this.form.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a map instance
|
||||
*/
|
||||
connectToMap(mapInstance) {
|
||||
this.options.mapInstance = mapInstance;
|
||||
|
||||
// Listen to map events
|
||||
if (mapInstance.on) {
|
||||
mapInstance.on('boundsChange', (bounds) => {
|
||||
// Could update location-based filters here
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export current filters as URL parameters
|
||||
*/
|
||||
getFilterUrl(baseUrl = window.location.pathname) {
|
||||
const filters = this.getCurrentFilters();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => params.append(key, v));
|
||||
} else {
|
||||
params.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return params.toString() ? `${baseUrl}?${params}` : baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update URL with current filters (without page reload)
|
||||
*/
|
||||
updateUrl() {
|
||||
const url = this.getFilterUrl();
|
||||
if (window.history && window.history.pushState) {
|
||||
window.history.pushState(null, '', url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filter summary for display
|
||||
*/
|
||||
getFilterSummary() {
|
||||
const filters = this.getCurrentFilters();
|
||||
const summary = [];
|
||||
|
||||
// Location types
|
||||
if (filters.types) {
|
||||
const types = Array.isArray(filters.types) ? filters.types : [filters.types];
|
||||
summary.push(`Types: ${types.join(', ')}`);
|
||||
}
|
||||
|
||||
// Geographic filters
|
||||
if (filters.country) summary.push(`Country: ${filters.country}`);
|
||||
if (filters.state) summary.push(`State: ${filters.state}`);
|
||||
if (filters.city) summary.push(`City: ${filters.city}`);
|
||||
|
||||
// Search query
|
||||
if (filters.q) summary.push(`Search: "${filters.q}"`);
|
||||
|
||||
// Radius
|
||||
if (filters.radius) summary.push(`Within ${filters.radius} miles`);
|
||||
|
||||
return summary.length > 0 ? summary.join(' • ') : 'No filters applied';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to default filters
|
||||
*/
|
||||
resetToDefaults() {
|
||||
const defaults = {
|
||||
types: ['park'],
|
||||
cluster: 'true'
|
||||
};
|
||||
|
||||
this.setFilters(defaults);
|
||||
this.handleFilterChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the filter component
|
||||
*/
|
||||
destroy() {
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
}
|
||||
|
||||
// Remove event listeners would go here if we stored references
|
||||
// For now, rely on garbage collection
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize filter forms
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize map filters form
|
||||
const mapFiltersForm = document.getElementById('map-filters');
|
||||
if (mapFiltersForm) {
|
||||
window.mapFilters = new MapFilters('map-filters', {
|
||||
htmxUrl: mapFiltersForm.getAttribute('hx-get'),
|
||||
htmxTarget: mapFiltersForm.getAttribute('hx-target') || '#map-container'
|
||||
});
|
||||
|
||||
// Connect to map instance if available
|
||||
if (window.thrillwikiMap) {
|
||||
window.mapFilters.connectToMap(window.thrillwikiMap);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize other filter forms with data attributes
|
||||
const filterForms = document.querySelectorAll('[data-filter-form]');
|
||||
filterForms.forEach(form => {
|
||||
const options = {};
|
||||
|
||||
// Parse data attributes
|
||||
Object.keys(form.dataset).forEach(key => {
|
||||
if (key.startsWith('filter')) {
|
||||
const optionKey = key.replace('filter', '').toLowerCase();
|
||||
let value = form.dataset[key];
|
||||
|
||||
// Parse boolean values
|
||||
if (value === 'true') value = true;
|
||||
else if (value === 'false') value = false;
|
||||
|
||||
options[optionKey] = value;
|
||||
}
|
||||
});
|
||||
|
||||
new MapFilters(form.id, options);
|
||||
});
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = MapFilters;
|
||||
} else {
|
||||
window.MapFilters = MapFilters;
|
||||
}
|
||||
@@ -1,553 +0,0 @@
|
||||
/**
|
||||
* ThrillWiki Map Integration - Master Integration Script
|
||||
*
|
||||
* This module coordinates all map components, handles initialization order,
|
||||
* manages component communication, and provides a unified API
|
||||
*/
|
||||
|
||||
class MapIntegration {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
autoInit: true,
|
||||
enableLogging: true,
|
||||
enablePerformanceMonitoring: true,
|
||||
initTimeout: 10000,
|
||||
retryAttempts: 3,
|
||||
components: {
|
||||
maps: true,
|
||||
filters: true,
|
||||
roadtrip: true,
|
||||
geolocation: true,
|
||||
markers: true,
|
||||
htmx: true,
|
||||
mobileTouch: true,
|
||||
darkMode: true
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
this.components = {};
|
||||
this.initOrder = [
|
||||
'darkMode',
|
||||
'mobileTouch',
|
||||
'maps',
|
||||
'markers',
|
||||
'filters',
|
||||
'geolocation',
|
||||
'htmx',
|
||||
'roadtrip'
|
||||
];
|
||||
this.initialized = false;
|
||||
this.initStartTime = null;
|
||||
this.errors = [];
|
||||
|
||||
if (this.options.autoInit) {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all map components
|
||||
*/
|
||||
async init() {
|
||||
this.initStartTime = performance.now();
|
||||
this.log('Starting map integration initialization...');
|
||||
|
||||
try {
|
||||
// Wait for DOM to be ready
|
||||
await this.waitForDOM();
|
||||
|
||||
// Initialize components in order
|
||||
await this.initializeComponents();
|
||||
|
||||
// Connect components
|
||||
this.connectComponents();
|
||||
|
||||
// Setup global event handlers
|
||||
this.setupGlobalHandlers();
|
||||
|
||||
// Verify integration
|
||||
this.verifyIntegration();
|
||||
|
||||
this.initialized = true;
|
||||
this.logPerformance();
|
||||
this.log('Map integration initialized successfully');
|
||||
|
||||
// Emit ready event
|
||||
this.emitEvent('mapIntegrationReady', {
|
||||
components: Object.keys(this.components),
|
||||
initTime: performance.now() - this.initStartTime
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.handleInitError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for DOM to be ready
|
||||
*/
|
||||
waitForDOM() {
|
||||
return new Promise((resolve) => {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', resolve);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize components in the correct order
|
||||
*/
|
||||
async initializeComponents() {
|
||||
for (const componentName of this.initOrder) {
|
||||
if (!this.options.components[componentName]) {
|
||||
this.log(`Skipping ${componentName} (disabled)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.initializeComponent(componentName);
|
||||
this.log(`✓ ${componentName} initialized`);
|
||||
} catch (error) {
|
||||
this.error(`✗ Failed to initialize ${componentName}:`, error);
|
||||
this.errors.push({ component: componentName, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize individual component
|
||||
*/
|
||||
async initializeComponent(componentName) {
|
||||
switch (componentName) {
|
||||
case 'darkMode':
|
||||
if (window.DarkModeMaps) {
|
||||
this.components.darkMode = window.darkModeMaps || new DarkModeMaps();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'mobileTouch':
|
||||
if (window.MobileTouchSupport) {
|
||||
this.components.mobileTouch = window.mobileTouchSupport || new MobileTouchSupport();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'maps':
|
||||
// Look for existing map instances or create new ones
|
||||
if (window.thrillwikiMap) {
|
||||
this.components.maps = window.thrillwikiMap;
|
||||
} else if (window.ThrillWikiMap) {
|
||||
const mapContainer = document.getElementById('map-container');
|
||||
if (mapContainer) {
|
||||
this.components.maps = new ThrillWikiMap('map-container');
|
||||
window.thrillwikiMap = this.components.maps;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'markers':
|
||||
if (window.MapMarkers && this.components.maps) {
|
||||
this.components.markers = window.mapMarkers || new MapMarkers(this.components.maps);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'filters':
|
||||
if (window.MapFilters) {
|
||||
const filterForm = document.getElementById('map-filters');
|
||||
if (filterForm) {
|
||||
this.components.filters = window.mapFilters || new MapFilters('map-filters');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'geolocation':
|
||||
if (window.UserLocation) {
|
||||
this.components.geolocation = window.userLocation || new UserLocation();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'htmx':
|
||||
if (window.HTMXMapIntegration && typeof htmx !== 'undefined') {
|
||||
this.components.htmx = window.htmxMapIntegration || new HTMXMapIntegration();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'roadtrip':
|
||||
if (window.RoadTripPlanner) {
|
||||
const roadtripContainer = document.getElementById('roadtrip-planner');
|
||||
if (roadtripContainer) {
|
||||
this.components.roadtrip = window.roadTripPlanner || new RoadTripPlanner('roadtrip-planner');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect components together
|
||||
*/
|
||||
connectComponents() {
|
||||
this.log('Connecting components...');
|
||||
|
||||
// Connect maps to other components
|
||||
if (this.components.maps) {
|
||||
// Connect to dark mode
|
||||
if (this.components.darkMode) {
|
||||
this.components.darkMode.registerMapInstance(this.components.maps);
|
||||
}
|
||||
|
||||
// Connect to mobile touch
|
||||
if (this.components.mobileTouch) {
|
||||
this.components.mobileTouch.registerMapInstance(this.components.maps);
|
||||
}
|
||||
|
||||
// Connect to geolocation
|
||||
if (this.components.geolocation) {
|
||||
this.components.geolocation.connectToMap(this.components.maps);
|
||||
}
|
||||
|
||||
// Connect to road trip planner
|
||||
if (this.components.roadtrip) {
|
||||
this.components.roadtrip.connectToMap(this.components.maps);
|
||||
}
|
||||
}
|
||||
|
||||
// Connect filters to other components
|
||||
if (this.components.filters) {
|
||||
// Connect to maps
|
||||
if (this.components.maps) {
|
||||
this.components.filters.connectToMap(this.components.maps);
|
||||
}
|
||||
|
||||
// Connect to HTMX
|
||||
if (this.components.htmx) {
|
||||
this.components.htmx.connectToFilter(this.components.filters);
|
||||
}
|
||||
}
|
||||
|
||||
// Connect HTMX to maps
|
||||
if (this.components.htmx && this.components.maps) {
|
||||
this.components.htmx.connectToMap(this.components.maps);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup global event handlers
|
||||
*/
|
||||
setupGlobalHandlers() {
|
||||
// Handle global map events
|
||||
document.addEventListener('mapDataUpdate', (e) => {
|
||||
this.handleMapDataUpdate(e.detail);
|
||||
});
|
||||
|
||||
// Handle filter changes
|
||||
document.addEventListener('filterChange', (e) => {
|
||||
this.handleFilterChange(e.detail);
|
||||
});
|
||||
|
||||
// Handle theme changes
|
||||
document.addEventListener('themeChanged', (e) => {
|
||||
this.handleThemeChange(e.detail);
|
||||
});
|
||||
|
||||
// Handle orientation changes
|
||||
document.addEventListener('orientationChanged', (e) => {
|
||||
this.handleOrientationChange(e.detail);
|
||||
});
|
||||
|
||||
// Handle visibility changes for performance
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
this.handleVisibilityChange();
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
window.addEventListener('error', (e) => {
|
||||
if (this.isMapRelatedError(e)) {
|
||||
this.handleGlobalError(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle map data updates
|
||||
*/
|
||||
handleMapDataUpdate(data) {
|
||||
if (this.components.maps) {
|
||||
this.components.maps.updateMarkers(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle filter changes
|
||||
*/
|
||||
handleFilterChange(filters) {
|
||||
if (this.components.maps) {
|
||||
this.components.maps.updateFilters(filters);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle theme changes
|
||||
*/
|
||||
handleThemeChange(themeData) {
|
||||
// All components should already be listening for this
|
||||
// Just log for monitoring
|
||||
this.log(`Theme changed to ${themeData.newTheme}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle orientation changes
|
||||
*/
|
||||
handleOrientationChange(orientationData) {
|
||||
// Invalidate map sizes after orientation change
|
||||
if (this.components.maps) {
|
||||
setTimeout(() => {
|
||||
this.components.maps.invalidateSize();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle visibility changes
|
||||
*/
|
||||
handleVisibilityChange() {
|
||||
const isHidden = document.hidden;
|
||||
|
||||
// Pause/resume location watching
|
||||
if (this.components.geolocation) {
|
||||
if (isHidden) {
|
||||
this.components.geolocation.stopWatching();
|
||||
} else if (this.components.geolocation.options.watchPosition) {
|
||||
this.components.geolocation.startWatching();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is map-related
|
||||
*/
|
||||
isMapRelatedError(error) {
|
||||
const mapKeywords = ['leaflet', 'map', 'marker', 'tile', 'geolocation', 'htmx'];
|
||||
const errorMessage = error.message ? error.message.toLowerCase() : '';
|
||||
const errorStack = error.error && error.error.stack ? error.error.stack.toLowerCase() : '';
|
||||
|
||||
return mapKeywords.some(keyword =>
|
||||
errorMessage.includes(keyword) || errorStack.includes(keyword)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle global errors
|
||||
*/
|
||||
handleGlobalError(error) {
|
||||
this.error('Global map error:', error);
|
||||
this.errors.push({ type: 'global', error });
|
||||
|
||||
// Emit error event
|
||||
this.emitEvent('mapError', { error, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify integration is working
|
||||
*/
|
||||
verifyIntegration() {
|
||||
const issues = [];
|
||||
|
||||
// Check required components
|
||||
if (this.options.components.maps && !this.components.maps) {
|
||||
issues.push('Maps component not initialized');
|
||||
}
|
||||
|
||||
// Check component connections
|
||||
if (this.components.maps && this.components.darkMode) {
|
||||
if (!this.components.darkMode.mapInstances.has(this.components.maps)) {
|
||||
issues.push('Maps not connected to dark mode');
|
||||
}
|
||||
}
|
||||
|
||||
// Check DOM elements
|
||||
const mapContainer = document.getElementById('map-container');
|
||||
if (this.components.maps && !mapContainer) {
|
||||
issues.push('Map container not found in DOM');
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
this.warn('Integration issues found:', issues);
|
||||
}
|
||||
|
||||
return issues.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle initialization errors
|
||||
*/
|
||||
handleInitError(error) {
|
||||
this.error('Map integration initialization failed:', error);
|
||||
|
||||
// Emit error event
|
||||
this.emitEvent('mapIntegrationError', {
|
||||
error,
|
||||
errors: this.errors,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Try to initialize what we can
|
||||
this.attemptPartialInit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt partial initialization
|
||||
*/
|
||||
attemptPartialInit() {
|
||||
this.log('Attempting partial initialization...');
|
||||
|
||||
// Try to initialize at least the core map
|
||||
if (!this.components.maps && window.ThrillWikiMap) {
|
||||
try {
|
||||
const mapContainer = document.getElementById('map-container');
|
||||
if (mapContainer) {
|
||||
this.components.maps = new ThrillWikiMap('map-container');
|
||||
this.log('✓ Core map initialized in fallback mode');
|
||||
}
|
||||
} catch (error) {
|
||||
this.error('✗ Fallback map initialization failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get component by name
|
||||
*/
|
||||
getComponent(name) {
|
||||
return this.components[name] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all components
|
||||
*/
|
||||
getAllComponents() {
|
||||
return { ...this.components };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if integration is ready
|
||||
*/
|
||||
isReady() {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initialization status
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
initialized: this.initialized,
|
||||
components: Object.keys(this.components),
|
||||
errors: this.errors,
|
||||
initTime: this.initStartTime ? performance.now() - this.initStartTime : null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit custom event
|
||||
*/
|
||||
emitEvent(eventName, detail) {
|
||||
const event = new CustomEvent(eventName, { detail });
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log performance metrics
|
||||
*/
|
||||
logPerformance() {
|
||||
if (!this.options.enablePerformanceMonitoring) return;
|
||||
|
||||
const initTime = performance.now() - this.initStartTime;
|
||||
const componentCount = Object.keys(this.components).length;
|
||||
|
||||
this.log(`Performance: ${initTime.toFixed(2)}ms to initialize ${componentCount} components`);
|
||||
|
||||
// Send to analytics if available
|
||||
if (typeof gtag !== 'undefined') {
|
||||
gtag('event', 'map_integration_performance', {
|
||||
event_category: 'performance',
|
||||
value: Math.round(initTime),
|
||||
custom_map: {
|
||||
component_count: componentCount,
|
||||
errors: this.errors.length
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logging methods
|
||||
*/
|
||||
log(message, ...args) {
|
||||
if (this.options.enableLogging) {
|
||||
console.log(`[MapIntegration] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
warn(message, ...args) {
|
||||
if (this.options.enableLogging) {
|
||||
console.warn(`[MapIntegration] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
error(message, ...args) {
|
||||
if (this.options.enableLogging) {
|
||||
console.error(`[MapIntegration] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy integration
|
||||
*/
|
||||
destroy() {
|
||||
// Destroy all components
|
||||
Object.values(this.components).forEach(component => {
|
||||
if (component && typeof component.destroy === 'function') {
|
||||
try {
|
||||
component.destroy();
|
||||
} catch (error) {
|
||||
this.error('Error destroying component:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.components = {};
|
||||
this.initialized = false;
|
||||
this.log('Map integration destroyed');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize map integration
|
||||
let mapIntegration;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Only initialize if we have map-related elements
|
||||
const hasMapElements = document.querySelector('#map-container, .map-container, [data-map], [data-roadtrip]');
|
||||
|
||||
if (hasMapElements) {
|
||||
mapIntegration = new MapIntegration();
|
||||
window.mapIntegration = mapIntegration;
|
||||
}
|
||||
});
|
||||
|
||||
// Global API for external access
|
||||
window.ThrillWikiMaps = {
|
||||
getIntegration: () => mapIntegration,
|
||||
isReady: () => mapIntegration && mapIntegration.isReady(),
|
||||
getComponent: (name) => mapIntegration ? mapIntegration.getComponent(name) : null,
|
||||
getStatus: () => mapIntegration ? mapIntegration.getStatus() : { initialized: false }
|
||||
};
|
||||
|
||||
// Export for module systems
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = MapIntegration;
|
||||
} else {
|
||||
window.MapIntegration = MapIntegration;
|
||||
}
|
||||
@@ -1,850 +0,0 @@
|
||||
/**
|
||||
* ThrillWiki Map Markers - Custom Marker Icons and Rich Popup System
|
||||
*
|
||||
* This module handles custom marker icons for different location types,
|
||||
* rich popup content with location details, and performance optimization
|
||||
*/
|
||||
|
||||
class MapMarkers {
|
||||
constructor(mapInstance, options = {}) {
|
||||
this.mapInstance = mapInstance;
|
||||
this.options = {
|
||||
enableClustering: true,
|
||||
clusterDistance: 50,
|
||||
enableCustomIcons: true,
|
||||
enableRichPopups: true,
|
||||
enableMarkerAnimation: true,
|
||||
popupMaxWidth: 300,
|
||||
iconTheme: 'modern', // 'modern', 'classic', 'emoji'
|
||||
apiEndpoints: {
|
||||
details: '/api/map/location-detail/',
|
||||
media: '/api/media/'
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
this.markerStyles = this.initializeMarkerStyles();
|
||||
this.iconCache = new Map();
|
||||
this.popupCache = new Map();
|
||||
this.activePopup = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the marker system
|
||||
*/
|
||||
init() {
|
||||
this.setupMarkerStyles();
|
||||
this.setupClusterStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize marker style definitions
|
||||
*/
|
||||
initializeMarkerStyles() {
|
||||
return {
|
||||
park: {
|
||||
operating: {
|
||||
color: '#10B981',
|
||||
emoji: '🎢',
|
||||
icon: 'fas fa-tree',
|
||||
size: 'large'
|
||||
},
|
||||
closed_temp: {
|
||||
color: '#F59E0B',
|
||||
emoji: '🚧',
|
||||
icon: 'fas fa-clock',
|
||||
size: 'medium'
|
||||
},
|
||||
closed_perm: {
|
||||
color: '#EF4444',
|
||||
emoji: '❌',
|
||||
icon: 'fas fa-times-circle',
|
||||
size: 'medium'
|
||||
},
|
||||
under_construction: {
|
||||
color: '#8B5CF6',
|
||||
emoji: '🏗️',
|
||||
icon: 'fas fa-hard-hat',
|
||||
size: 'medium'
|
||||
},
|
||||
demolished: {
|
||||
color: '#6B7280',
|
||||
emoji: '🏚️',
|
||||
icon: 'fas fa-ban',
|
||||
size: 'small'
|
||||
}
|
||||
},
|
||||
ride: {
|
||||
operating: {
|
||||
color: '#3B82F6',
|
||||
emoji: '🎠',
|
||||
icon: 'fas fa-rocket',
|
||||
size: 'medium'
|
||||
},
|
||||
closed_temp: {
|
||||
color: '#F59E0B',
|
||||
emoji: '⏸️',
|
||||
icon: 'fas fa-pause-circle',
|
||||
size: 'small'
|
||||
},
|
||||
closed_perm: {
|
||||
color: '#EF4444',
|
||||
emoji: '❌',
|
||||
icon: 'fas fa-times-circle',
|
||||
size: 'small'
|
||||
},
|
||||
under_construction: {
|
||||
color: '#8B5CF6',
|
||||
emoji: '🔨',
|
||||
icon: 'fas fa-tools',
|
||||
size: 'small'
|
||||
},
|
||||
removed: {
|
||||
color: '#6B7280',
|
||||
emoji: '💔',
|
||||
icon: 'fas fa-trash',
|
||||
size: 'small'
|
||||
}
|
||||
},
|
||||
company: {
|
||||
manufacturer: {
|
||||
color: '#8B5CF6',
|
||||
emoji: '🏭',
|
||||
icon: 'fas fa-industry',
|
||||
size: 'medium'
|
||||
},
|
||||
operator: {
|
||||
color: '#059669',
|
||||
emoji: '🏢',
|
||||
icon: 'fas fa-building',
|
||||
size: 'medium'
|
||||
},
|
||||
designer: {
|
||||
color: '#DC2626',
|
||||
emoji: '🎨',
|
||||
icon: 'fas fa-pencil-ruler',
|
||||
size: 'medium'
|
||||
}
|
||||
},
|
||||
user: {
|
||||
current: {
|
||||
color: '#3B82F6',
|
||||
emoji: '📍',
|
||||
icon: 'fas fa-crosshairs',
|
||||
size: 'medium'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup marker styles in CSS
|
||||
*/
|
||||
setupMarkerStyles() {
|
||||
if (document.getElementById('map-marker-styles')) return;
|
||||
|
||||
const styles = `
|
||||
<style id="map-marker-styles">
|
||||
.location-marker {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.location-marker:hover {
|
||||
transform: scale(1.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.location-marker-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
border: 3px solid white;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.location-marker-inner::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 8px solid inherit;
|
||||
}
|
||||
|
||||
.location-marker.size-small .location-marker-inner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.location-marker.size-medium .location-marker-inner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.location-marker.size-large .location-marker-inner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.location-marker-emoji {
|
||||
font-size: 1.2em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.location-marker-icon {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Cluster markers */
|
||||
.cluster-marker {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cluster-marker-inner {
|
||||
background: #3B82F6;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.cluster-marker:hover .cluster-marker-inner {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.cluster-marker-small .cluster-marker-inner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cluster-marker-medium .cluster-marker-inner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.cluster-marker-large .cluster-marker-inner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
background: #DC2626;
|
||||
}
|
||||
|
||||
/* Popup styles */
|
||||
.location-popup {
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 5px 0;
|
||||
color: #1F2937;
|
||||
}
|
||||
|
||||
.popup-subtitle {
|
||||
font-size: 12px;
|
||||
color: #6B7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.popup-detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.popup-detail i {
|
||||
width: 16px;
|
||||
margin-right: 6px;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.popup-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.popup-btn-primary {
|
||||
background: #3B82F6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.popup-btn-primary:hover {
|
||||
background: #2563EB;
|
||||
}
|
||||
|
||||
.popup-btn-secondary {
|
||||
background: #F3F4F6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.popup-btn-secondary:hover {
|
||||
background: #E5E7EB;
|
||||
}
|
||||
|
||||
.popup-image {
|
||||
width: 100%;
|
||||
max-height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.popup-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.popup-status.operating {
|
||||
background: #D1FAE5;
|
||||
color: #065F46;
|
||||
}
|
||||
|
||||
.popup-status.closed {
|
||||
background: #FEE2E2;
|
||||
color: #991B1B;
|
||||
}
|
||||
|
||||
.popup-status.construction {
|
||||
background: #EDE9FE;
|
||||
color: #5B21B6;
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
.dark .popup-title {
|
||||
color: #F9FAFB;
|
||||
}
|
||||
|
||||
.dark .popup-detail {
|
||||
color: #D1D5DB;
|
||||
}
|
||||
|
||||
.dark .popup-btn-secondary {
|
||||
background: #374151;
|
||||
color: #D1D5DB;
|
||||
}
|
||||
|
||||
.dark .popup-btn-secondary:hover {
|
||||
background: #4B5563;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
document.head.insertAdjacentHTML('beforeend', styles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup cluster marker styles
|
||||
*/
|
||||
setupClusterStyles() {
|
||||
// Additional cluster-specific styles if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a location marker
|
||||
*/
|
||||
createLocationMarker(location) {
|
||||
const iconData = this.getMarkerIconData(location);
|
||||
const icon = this.createCustomIcon(iconData, location);
|
||||
|
||||
const marker = L.marker([location.latitude, location.longitude], {
|
||||
icon: icon,
|
||||
locationData: location,
|
||||
riseOnHover: true
|
||||
});
|
||||
|
||||
// Create popup
|
||||
if (this.options.enableRichPopups) {
|
||||
const popupContent = this.createPopupContent(location);
|
||||
marker.bindPopup(popupContent, {
|
||||
maxWidth: this.options.popupMaxWidth,
|
||||
className: 'location-popup-container'
|
||||
});
|
||||
}
|
||||
|
||||
// Add click handler
|
||||
marker.on('click', (e) => {
|
||||
this.handleMarkerClick(marker, location);
|
||||
});
|
||||
|
||||
// Add hover effects if animation is enabled
|
||||
if (this.options.enableMarkerAnimation) {
|
||||
marker.on('mouseover', () => {
|
||||
const iconElement = marker.getElement();
|
||||
if (iconElement) {
|
||||
iconElement.style.transform = 'scale(1.1)';
|
||||
iconElement.style.zIndex = '1000';
|
||||
}
|
||||
});
|
||||
|
||||
marker.on('mouseout', () => {
|
||||
const iconElement = marker.getElement();
|
||||
if (iconElement) {
|
||||
iconElement.style.transform = 'scale(1)';
|
||||
iconElement.style.zIndex = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get marker icon data based on location type and status
|
||||
*/
|
||||
getMarkerIconData(location) {
|
||||
const type = location.type || 'generic';
|
||||
const status = location.status || 'operating';
|
||||
|
||||
// Get style data
|
||||
const typeStyles = this.markerStyles[type];
|
||||
if (!typeStyles) {
|
||||
return this.markerStyles.park.operating;
|
||||
}
|
||||
|
||||
const statusStyle = typeStyles[status.toLowerCase()];
|
||||
if (!statusStyle) {
|
||||
// Fallback to first available status for this type
|
||||
const firstStatus = Object.keys(typeStyles)[0];
|
||||
return typeStyles[firstStatus];
|
||||
}
|
||||
|
||||
return statusStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom icon
|
||||
*/
|
||||
createCustomIcon(iconData, location) {
|
||||
const cacheKey = `${location.type}-${location.status}-${this.options.iconTheme}`;
|
||||
|
||||
if (this.iconCache.has(cacheKey)) {
|
||||
return this.iconCache.get(cacheKey);
|
||||
}
|
||||
|
||||
let iconHtml;
|
||||
|
||||
switch (this.options.iconTheme) {
|
||||
case 'emoji':
|
||||
iconHtml = `<span class="location-marker-emoji">${iconData.emoji}</span>`;
|
||||
break;
|
||||
case 'classic':
|
||||
iconHtml = `<i class="location-marker-icon ${iconData.icon}"></i>`;
|
||||
break;
|
||||
case 'modern':
|
||||
default:
|
||||
iconHtml = location.featured_image ?
|
||||
`<img src="${location.featured_image}" alt="${location.name}" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">` :
|
||||
`<i class="location-marker-icon ${iconData.icon}"></i>`;
|
||||
break;
|
||||
}
|
||||
|
||||
const sizeClass = iconData.size || 'medium';
|
||||
const size = sizeClass === 'small' ? 24 : sizeClass === 'large' ? 40 : 32;
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: `location-marker size-${sizeClass}`,
|
||||
html: `<div class="location-marker-inner" style="background-color: ${iconData.color}">${iconHtml}</div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
popupAnchor: [0, -(size / 2) - 8]
|
||||
});
|
||||
|
||||
this.iconCache.set(cacheKey, icon);
|
||||
return icon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rich popup content
|
||||
*/
|
||||
createPopupContent(location) {
|
||||
const cacheKey = `popup-${location.type}-${location.id}`;
|
||||
|
||||
if (this.popupCache.has(cacheKey)) {
|
||||
return this.popupCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const statusClass = this.getStatusClass(location.status);
|
||||
|
||||
const content = `
|
||||
<div class="location-popup">
|
||||
${location.featured_image ? `
|
||||
<img src="${location.featured_image}" alt="${location.name}" class="popup-image">
|
||||
` : ''}
|
||||
|
||||
<div class="popup-header">
|
||||
<h3 class="popup-title">${this.escapeHtml(location.name)}</h3>
|
||||
${location.type ? `<p class="popup-subtitle">${this.capitalizeFirst(location.type)}</p>` : ''}
|
||||
${location.status ? `<span class="popup-status ${statusClass}">${this.formatStatus(location.status)}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="popup-content">
|
||||
${this.createPopupDetails(location)}
|
||||
</div>
|
||||
|
||||
<div class="popup-actions">
|
||||
${this.createPopupActions(location)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.popupCache.set(cacheKey, content);
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create popup detail items
|
||||
*/
|
||||
createPopupDetails(location) {
|
||||
const details = [];
|
||||
|
||||
if (location.formatted_location) {
|
||||
details.push(`
|
||||
<div class="popup-detail">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
<span>${this.escapeHtml(location.formatted_location)}</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
if (location.operator) {
|
||||
details.push(`
|
||||
<div class="popup-detail">
|
||||
<i class="fas fa-building"></i>
|
||||
<span>${this.escapeHtml(location.operator)}</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
if (location.ride_count && location.ride_count > 0) {
|
||||
details.push(`
|
||||
<div class="popup-detail">
|
||||
<i class="fas fa-rocket"></i>
|
||||
<span>${location.ride_count} ride${location.ride_count === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
if (location.opened_date) {
|
||||
details.push(`
|
||||
<div class="popup-detail">
|
||||
<i class="fas fa-calendar"></i>
|
||||
<span>Opened ${this.formatDate(location.opened_date)}</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
if (location.manufacturer) {
|
||||
details.push(`
|
||||
<div class="popup-detail">
|
||||
<i class="fas fa-industry"></i>
|
||||
<span>${this.escapeHtml(location.manufacturer)}</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
if (location.designer) {
|
||||
details.push(`
|
||||
<div class="popup-detail">
|
||||
<i class="fas fa-pencil-ruler"></i>
|
||||
<span>${this.escapeHtml(location.designer)}</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
return details.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create popup action buttons
|
||||
*/
|
||||
createPopupActions(location) {
|
||||
const actions = [];
|
||||
|
||||
// View details button
|
||||
actions.push(`
|
||||
<button onclick="mapMarkers.showLocationDetails('${location.type}', ${location.id})"
|
||||
class="popup-btn popup-btn-primary">
|
||||
<i class="fas fa-eye"></i>
|
||||
View Details
|
||||
</button>
|
||||
`);
|
||||
|
||||
// Add to road trip (for parks)
|
||||
if (location.type === 'park' && window.roadTripPlanner) {
|
||||
actions.push(`
|
||||
<button onclick="roadTripPlanner.addPark(${location.id})"
|
||||
class="popup-btn popup-btn-secondary">
|
||||
<i class="fas fa-route"></i>
|
||||
Add to Trip
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
|
||||
// Get directions
|
||||
if (location.latitude && location.longitude) {
|
||||
const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${location.latitude},${location.longitude}`;
|
||||
actions.push(`
|
||||
<a href="${mapsUrl}" target="_blank"
|
||||
class="popup-btn popup-btn-secondary">
|
||||
<i class="fas fa-directions"></i>
|
||||
Directions
|
||||
</a>
|
||||
`);
|
||||
}
|
||||
|
||||
return actions.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle marker click events
|
||||
*/
|
||||
handleMarkerClick(marker, location) {
|
||||
this.activePopup = marker.getPopup();
|
||||
|
||||
// Load additional data if needed
|
||||
this.loadLocationDetails(location);
|
||||
|
||||
// Track click event
|
||||
if (typeof gtag !== 'undefined') {
|
||||
gtag('event', 'marker_click', {
|
||||
event_category: 'map',
|
||||
event_label: `${location.type}:${location.id}`,
|
||||
custom_map: {
|
||||
location_type: location.type,
|
||||
location_name: location.name
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load additional location details
|
||||
*/
|
||||
async loadLocationDetails(location) {
|
||||
try {
|
||||
const response = await fetch(`${this.options.apiEndpoints.details}${location.type}/${location.id}/`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
// Update popup with additional details if popup is still open
|
||||
if (this.activePopup && this.activePopup.isOpen()) {
|
||||
const updatedContent = this.createPopupContent(data.data);
|
||||
this.activePopup.setContent(updatedContent);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load location details:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show location details modal/page
|
||||
*/
|
||||
showLocationDetails(type, id) {
|
||||
const url = `/${type}/${id}/`;
|
||||
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.ajax('GET', url, {
|
||||
target: '#location-modal',
|
||||
swap: 'innerHTML'
|
||||
}).then(() => {
|
||||
const modal = document.getElementById('location-modal');
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS class for status
|
||||
*/
|
||||
getStatusClass(status) {
|
||||
if (!status) return '';
|
||||
|
||||
const statusLower = status.toLowerCase();
|
||||
|
||||
if (statusLower.includes('operating') || statusLower.includes('open')) {
|
||||
return 'operating';
|
||||
} else if (statusLower.includes('closed') || statusLower.includes('temp')) {
|
||||
return 'closed';
|
||||
} else if (statusLower.includes('construction') || statusLower.includes('building')) {
|
||||
return 'construction';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format status for display
|
||||
*/
|
||||
formatStatus(status) {
|
||||
return status.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
formatDate(dateString) {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.getFullYear();
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize first letter
|
||||
*/
|
||||
capitalizeFirst(str) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create cluster marker
|
||||
*/
|
||||
createClusterMarker(cluster) {
|
||||
const count = cluster.getChildCount();
|
||||
let sizeClass = 'small';
|
||||
|
||||
if (count > 100) sizeClass = 'large';
|
||||
else if (count > 10) sizeClass = 'medium';
|
||||
|
||||
return L.divIcon({
|
||||
html: `<div class="cluster-marker-inner">${count}</div>`,
|
||||
className: `cluster-marker cluster-marker-${sizeClass}`,
|
||||
iconSize: L.point(sizeClass === 'small' ? 32 : sizeClass === 'medium' ? 40 : 48,
|
||||
sizeClass === 'small' ? 32 : sizeClass === 'medium' ? 40 : 48)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update marker theme
|
||||
*/
|
||||
setIconTheme(theme) {
|
||||
this.options.iconTheme = theme;
|
||||
this.iconCache.clear();
|
||||
|
||||
// Re-render all markers if map instance is available
|
||||
if (this.mapInstance && this.mapInstance.markers) {
|
||||
// This would need to be implemented in the main map class
|
||||
console.log(`Icon theme changed to: ${theme}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear popup cache
|
||||
*/
|
||||
clearPopupCache() {
|
||||
this.popupCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear icon cache
|
||||
*/
|
||||
clearIconCache() {
|
||||
this.iconCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get marker statistics
|
||||
*/
|
||||
getMarkerStats() {
|
||||
return {
|
||||
iconCacheSize: this.iconCache.size,
|
||||
popupCacheSize: this.popupCache.size,
|
||||
iconTheme: this.options.iconTheme
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize with map instance if available
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (window.thrillwikiMap) {
|
||||
window.mapMarkers = new MapMarkers(window.thrillwikiMap);
|
||||
}
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = MapMarkers;
|
||||
} else {
|
||||
window.MapMarkers = MapMarkers;
|
||||
}
|
||||
@@ -1,656 +0,0 @@
|
||||
/**
|
||||
* ThrillWiki Maps - Core Map Functionality
|
||||
*
|
||||
* This module provides the main map functionality for ThrillWiki using Leaflet.js
|
||||
* Includes clustering, filtering, dark mode support, and HTMX integration
|
||||
*/
|
||||
|
||||
class ThrillWikiMap {
|
||||
constructor(containerId, options = {}) {
|
||||
this.containerId = containerId;
|
||||
this.options = {
|
||||
center: [39.8283, -98.5795], // Center of USA
|
||||
zoom: 4,
|
||||
minZoom: 2,
|
||||
maxZoom: 18,
|
||||
enableClustering: true,
|
||||
enableDarkMode: true,
|
||||
enableGeolocation: false,
|
||||
apiEndpoints: {
|
||||
locations: '/api/map/locations/',
|
||||
details: '/api/map/location-detail/'
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
this.map = null;
|
||||
this.markers = null;
|
||||
this.currentData = [];
|
||||
this.userLocation = null;
|
||||
this.currentTileLayer = null;
|
||||
this.boundsUpdateTimeout = null;
|
||||
|
||||
// Event handlers
|
||||
this.eventHandlers = {
|
||||
locationClick: [],
|
||||
boundsChange: [],
|
||||
dataLoad: []
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the map
|
||||
*/
|
||||
init() {
|
||||
const container = document.getElementById(this.containerId);
|
||||
if (!container) {
|
||||
console.error(`Map container with ID '${this.containerId}' not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.initializeMap();
|
||||
this.setupTileLayers();
|
||||
this.setupClustering();
|
||||
this.bindEvents();
|
||||
this.loadInitialData();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize map:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Leaflet map instance
|
||||
*/
|
||||
initializeMap() {
|
||||
this.map = L.map(this.containerId, {
|
||||
center: this.options.center,
|
||||
zoom: this.options.zoom,
|
||||
minZoom: this.options.minZoom,
|
||||
maxZoom: this.options.maxZoom,
|
||||
zoomControl: false,
|
||||
attributionControl: false
|
||||
});
|
||||
|
||||
// Add custom zoom control
|
||||
L.control.zoom({
|
||||
position: 'bottomright'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Add attribution control
|
||||
L.control.attribution({
|
||||
position: 'bottomleft',
|
||||
prefix: false
|
||||
}).addTo(this.map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup tile layers with dark mode support
|
||||
*/
|
||||
setupTileLayers() {
|
||||
this.tileLayers = {
|
||||
light: L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
className: 'map-tiles-light'
|
||||
}),
|
||||
dark: L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors, © CARTO',
|
||||
className: 'map-tiles-dark'
|
||||
}),
|
||||
satellite: L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||||
attribution: '© Esri, DigitalGlobe, GeoEye, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community',
|
||||
className: 'map-tiles-satellite'
|
||||
})
|
||||
};
|
||||
|
||||
// Set initial tile layer based on theme
|
||||
this.updateTileLayer();
|
||||
|
||||
// Listen for theme changes if dark mode is enabled
|
||||
if (this.options.enableDarkMode) {
|
||||
this.observeThemeChanges();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup marker clustering
|
||||
*/
|
||||
setupClustering() {
|
||||
if (this.options.enableClustering) {
|
||||
this.markers = L.markerClusterGroup({
|
||||
chunkedLoading: true,
|
||||
maxClusterRadius: 50,
|
||||
spiderfyOnMaxZoom: true,
|
||||
showCoverageOnHover: false,
|
||||
zoomToBoundsOnClick: true,
|
||||
iconCreateFunction: (cluster) => {
|
||||
const count = cluster.getChildCount();
|
||||
let className = 'cluster-marker-small';
|
||||
|
||||
if (count > 100) className = 'cluster-marker-large';
|
||||
else if (count > 10) className = 'cluster-marker-medium';
|
||||
|
||||
return L.divIcon({
|
||||
html: `<div class="cluster-marker-inner">${count}</div>`,
|
||||
className: `cluster-marker ${className}`,
|
||||
iconSize: L.point(40, 40)
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.markers = L.layerGroup();
|
||||
}
|
||||
|
||||
this.map.addLayer(this.markers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind map events
|
||||
*/
|
||||
bindEvents() {
|
||||
// Map movement events
|
||||
this.map.on('moveend zoomend', () => {
|
||||
this.handleBoundsChange();
|
||||
});
|
||||
|
||||
// Marker click events
|
||||
this.markers.on('click', (e) => {
|
||||
if (e.layer.options && e.layer.options.locationData) {
|
||||
this.handleLocationClick(e.layer.options.locationData);
|
||||
}
|
||||
});
|
||||
|
||||
// Custom event handlers
|
||||
this.map.on('locationfound', (e) => {
|
||||
this.handleLocationFound(e);
|
||||
});
|
||||
|
||||
this.map.on('locationerror', (e) => {
|
||||
this.handleLocationError(e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe theme changes for automatic tile layer switching
|
||||
*/
|
||||
observeThemeChanges() {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
this.updateTileLayer();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tile layer based on current theme and settings
|
||||
*/
|
||||
updateTileLayer() {
|
||||
// Remove current tile layer
|
||||
if (this.currentTileLayer) {
|
||||
this.map.removeLayer(this.currentTileLayer);
|
||||
}
|
||||
|
||||
// Determine which layer to use
|
||||
let layerType = 'light';
|
||||
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
layerType = 'dark';
|
||||
}
|
||||
|
||||
// Check for satellite mode toggle
|
||||
const satelliteToggle = document.querySelector('input[name="satellite"]');
|
||||
if (satelliteToggle && satelliteToggle.checked) {
|
||||
layerType = 'satellite';
|
||||
}
|
||||
|
||||
// Add the appropriate tile layer
|
||||
this.currentTileLayer = this.tileLayers[layerType];
|
||||
this.map.addLayer(this.currentTileLayer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load initial map data
|
||||
*/
|
||||
async loadInitialData() {
|
||||
const bounds = this.map.getBounds();
|
||||
await this.loadLocations(bounds, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load locations with optional bounds and filters
|
||||
*/
|
||||
async loadLocations(bounds = null, filters = {}) {
|
||||
try {
|
||||
this.showLoading(true);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Add bounds if provided
|
||||
if (bounds) {
|
||||
params.append('north', bounds.getNorth());
|
||||
params.append('south', bounds.getSouth());
|
||||
params.append('east', bounds.getEast());
|
||||
params.append('west', bounds.getWest());
|
||||
}
|
||||
|
||||
// Add zoom level
|
||||
params.append('zoom', this.map.getZoom());
|
||||
|
||||
// Add filters
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => params.append(key, v));
|
||||
} else if (value !== null && value !== undefined && value !== '') {
|
||||
params.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(`${this.options.apiEndpoints.locations}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.updateMarkers(data.data);
|
||||
this.triggerEvent('dataLoad', data.data);
|
||||
} else {
|
||||
console.error('Map data error:', data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load map data:', error);
|
||||
} finally {
|
||||
this.showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update map markers with new data
|
||||
*/
|
||||
updateMarkers(data) {
|
||||
// Clear existing markers
|
||||
this.markers.clearLayers();
|
||||
this.currentData = data;
|
||||
|
||||
// Add location markers
|
||||
if (data.locations) {
|
||||
data.locations.forEach(location => {
|
||||
this.addLocationMarker(location);
|
||||
});
|
||||
}
|
||||
|
||||
// Add cluster markers (if not using Leaflet clustering)
|
||||
if (data.clusters && !this.options.enableClustering) {
|
||||
data.clusters.forEach(cluster => {
|
||||
this.addClusterMarker(cluster);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a location marker to the map
|
||||
*/
|
||||
addLocationMarker(location) {
|
||||
const icon = this.createLocationIcon(location);
|
||||
const marker = L.marker([location.latitude, location.longitude], {
|
||||
icon: icon,
|
||||
locationData: location
|
||||
});
|
||||
|
||||
// Create popup content
|
||||
const popupContent = this.createPopupContent(location);
|
||||
marker.bindPopup(popupContent, {
|
||||
maxWidth: 300,
|
||||
className: 'location-popup'
|
||||
});
|
||||
|
||||
this.markers.addLayer(marker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a cluster marker (for server-side clustering)
|
||||
*/
|
||||
addClusterMarker(cluster) {
|
||||
const marker = L.marker([cluster.latitude, cluster.longitude], {
|
||||
icon: L.divIcon({
|
||||
className: 'cluster-marker server-cluster',
|
||||
html: `<div class="cluster-marker-inner">${cluster.count}</div>`,
|
||||
iconSize: [40, 40]
|
||||
})
|
||||
});
|
||||
|
||||
marker.bindPopup(`${cluster.count} locations in this area`);
|
||||
this.markers.addLayer(marker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create location icon based on type
|
||||
*/
|
||||
createLocationIcon(location) {
|
||||
const iconMap = {
|
||||
'park': { emoji: '🎢', color: '#10B981' },
|
||||
'ride': { emoji: '🎠', color: '#3B82F6' },
|
||||
'company': { emoji: '🏢', color: '#8B5CF6' },
|
||||
'generic': { emoji: '📍', color: '#6B7280' }
|
||||
};
|
||||
|
||||
const iconData = iconMap[location.type] || iconMap.generic;
|
||||
|
||||
return L.divIcon({
|
||||
className: 'location-marker',
|
||||
html: `
|
||||
<div class="location-marker-inner" style="background-color: ${iconData.color}">
|
||||
<span class="location-marker-emoji">${iconData.emoji}</span>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
popupAnchor: [0, -15]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create popup content for a location
|
||||
*/
|
||||
createPopupContent(location) {
|
||||
return `
|
||||
<div class="location-info-popup">
|
||||
<h3 class="popup-title">${location.name}</h3>
|
||||
${location.formatted_location ? `<p class="popup-location"><i class="fas fa-map-marker-alt"></i>${location.formatted_location}</p>` : ''}
|
||||
${location.operator ? `<p class="popup-operator"><i class="fas fa-building"></i>${location.operator}</p>` : ''}
|
||||
${location.ride_count ? `<p class="popup-rides"><i class="fas fa-rocket"></i>${location.ride_count} rides</p>` : ''}
|
||||
${location.status ? `<p class="popup-status"><i class="fas fa-info-circle"></i>${location.status}</p>` : ''}
|
||||
<div class="popup-actions">
|
||||
<button onclick="window.thrillwikiMap.showLocationDetails('${location.type}', ${location.id})"
|
||||
class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-eye"></i> View Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide loading indicator
|
||||
*/
|
||||
showLoading(show) {
|
||||
const loadingElement = document.getElementById(`${this.containerId}-loading`) ||
|
||||
document.getElementById('map-loading');
|
||||
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle map bounds change
|
||||
*/
|
||||
handleBoundsChange() {
|
||||
clearTimeout(this.boundsUpdateTimeout);
|
||||
this.boundsUpdateTimeout = setTimeout(() => {
|
||||
const bounds = this.map.getBounds();
|
||||
this.triggerEvent('boundsChange', bounds);
|
||||
|
||||
// Auto-reload data on significant bounds change
|
||||
if (this.shouldReloadData()) {
|
||||
this.loadLocations(bounds, this.getCurrentFilters());
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle location click
|
||||
*/
|
||||
handleLocationClick(location) {
|
||||
this.triggerEvent('locationClick', location);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show location details (integrate with HTMX)
|
||||
*/
|
||||
showLocationDetails(type, id) {
|
||||
const url = `${this.options.apiEndpoints.details}${type}/${id}/`;
|
||||
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.ajax('GET', url, {
|
||||
target: '#location-modal',
|
||||
swap: 'innerHTML'
|
||||
}).then(() => {
|
||||
const modal = document.getElementById('location-modal');
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback to regular navigation
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current filters from form
|
||||
*/
|
||||
getCurrentFilters() {
|
||||
const form = document.getElementById('map-filters');
|
||||
if (!form) return {};
|
||||
|
||||
const formData = new FormData(form);
|
||||
const filters = {};
|
||||
|
||||
for (let [key, value] of formData.entries()) {
|
||||
if (filters[key]) {
|
||||
if (Array.isArray(filters[key])) {
|
||||
filters[key].push(value);
|
||||
} else {
|
||||
filters[key] = [filters[key], value];
|
||||
}
|
||||
} else {
|
||||
filters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update filters and reload data
|
||||
*/
|
||||
updateFilters(filters) {
|
||||
const bounds = this.map.getBounds();
|
||||
this.loadLocations(bounds, filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable user location features
|
||||
*/
|
||||
enableGeolocation() {
|
||||
this.options.enableGeolocation = true;
|
||||
this.map.locate({ setView: false, maxZoom: 16 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle location found
|
||||
*/
|
||||
handleLocationFound(e) {
|
||||
if (this.userLocation) {
|
||||
this.map.removeLayer(this.userLocation);
|
||||
}
|
||||
|
||||
this.userLocation = L.marker(e.latlng, {
|
||||
icon: L.divIcon({
|
||||
className: 'user-location-marker',
|
||||
html: '<div class="user-location-inner"><i class="fas fa-crosshairs"></i></div>',
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
})
|
||||
}).addTo(this.map);
|
||||
|
||||
this.userLocation.bindPopup('Your Location');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle location error
|
||||
*/
|
||||
handleLocationError(e) {
|
||||
console.warn('Location access denied or unavailable:', e.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if data should be reloaded based on map movement
|
||||
*/
|
||||
shouldReloadData() {
|
||||
// Simple heuristic: reload if zoom changed or moved significantly
|
||||
return true; // For now, always reload
|
||||
}
|
||||
|
||||
/**
|
||||
* Add event listener
|
||||
*/
|
||||
on(event, handler) {
|
||||
if (!this.eventHandlers[event]) {
|
||||
this.eventHandlers[event] = [];
|
||||
}
|
||||
this.eventHandlers[event].push(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove event listener
|
||||
*/
|
||||
off(event, handler) {
|
||||
if (this.eventHandlers[event]) {
|
||||
const index = this.eventHandlers[event].indexOf(handler);
|
||||
if (index > -1) {
|
||||
this.eventHandlers[event].splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger event
|
||||
*/
|
||||
triggerEvent(event, data) {
|
||||
if (this.eventHandlers[event]) {
|
||||
this.eventHandlers[event].forEach(handler => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error(`Error in ${event} handler:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export map view as image (requires html2canvas)
|
||||
*/
|
||||
async exportMap() {
|
||||
if (typeof html2canvas === 'undefined') {
|
||||
console.warn('html2canvas library not loaded, cannot export map');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(document.getElementById(this.containerId));
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch (error) {
|
||||
console.error('Failed to export map:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize map (call when container size changes)
|
||||
*/
|
||||
invalidateSize() {
|
||||
if (this.map) {
|
||||
this.map.invalidateSize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get map bounds
|
||||
*/
|
||||
getBounds() {
|
||||
return this.map ? this.map.getBounds() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set map view
|
||||
*/
|
||||
setView(latlng, zoom) {
|
||||
if (this.map) {
|
||||
this.map.setView(latlng, zoom);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit map to bounds
|
||||
*/
|
||||
fitBounds(bounds, options = {}) {
|
||||
if (this.map) {
|
||||
this.map.fitBounds(bounds, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy map instance
|
||||
*/
|
||||
destroy() {
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
this.map = null;
|
||||
}
|
||||
|
||||
// Clear timeouts
|
||||
if (this.boundsUpdateTimeout) {
|
||||
clearTimeout(this.boundsUpdateTimeout);
|
||||
}
|
||||
|
||||
// Clear event handlers
|
||||
this.eventHandlers = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize maps with data attributes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Find all elements with map-container class
|
||||
const mapContainers = document.querySelectorAll('[data-map="auto"]');
|
||||
|
||||
mapContainers.forEach(container => {
|
||||
const mapId = container.id;
|
||||
const options = {};
|
||||
|
||||
// Parse data attributes for configuration
|
||||
Object.keys(container.dataset).forEach(key => {
|
||||
if (key.startsWith('map')) {
|
||||
const optionKey = key.replace('map', '').toLowerCase();
|
||||
let value = container.dataset[key];
|
||||
|
||||
// Try to parse as JSON for complex values
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (e) {
|
||||
// Keep as string if not valid JSON
|
||||
}
|
||||
|
||||
options[optionKey] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Create map instance
|
||||
window[`${mapId}Instance`] = new ThrillWikiMap(mapId, options);
|
||||
});
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = ThrillWikiMap;
|
||||
} else {
|
||||
window.ThrillWikiMap = ThrillWikiMap;
|
||||
}
|
||||
@@ -1,881 +0,0 @@
|
||||
/**
|
||||
* ThrillWiki Mobile Touch Support - Enhanced Mobile and Touch Experience
|
||||
*
|
||||
* This module provides mobile-optimized interactions, touch-friendly controls,
|
||||
* responsive map sizing, and battery-conscious features for mobile devices
|
||||
*/
|
||||
|
||||
class MobileTouchSupport {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
enableTouchOptimizations: true,
|
||||
enableSwipeGestures: true,
|
||||
enablePinchZoom: true,
|
||||
enableResponsiveResize: true,
|
||||
enableBatteryOptimization: true,
|
||||
touchDebounceDelay: 150,
|
||||
swipeThreshold: 50,
|
||||
swipeVelocityThreshold: 0.3,
|
||||
maxTouchPoints: 2,
|
||||
orientationChangeDelay: 300,
|
||||
...options
|
||||
};
|
||||
|
||||
this.isMobile = this.detectMobileDevice();
|
||||
this.isTouch = this.detectTouchSupport();
|
||||
this.orientation = this.getOrientation();
|
||||
this.mapInstances = new Set();
|
||||
this.touchHandlers = new Map();
|
||||
this.gestureState = {
|
||||
isActive: false,
|
||||
startDistance: 0,
|
||||
startCenter: null,
|
||||
lastTouchTime: 0
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize mobile touch support
|
||||
*/
|
||||
init() {
|
||||
if (!this.isTouch && !this.isMobile) {
|
||||
console.log('Mobile touch support not needed for this device');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setupTouchOptimizations();
|
||||
this.setupSwipeGestures();
|
||||
this.setupResponsiveHandling();
|
||||
this.setupBatteryOptimization();
|
||||
this.setupAccessibilityEnhancements();
|
||||
this.bindEventHandlers();
|
||||
|
||||
console.log('Mobile touch support initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if device is mobile
|
||||
*/
|
||||
detectMobileDevice() {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const mobileKeywords = ['android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone'];
|
||||
|
||||
return mobileKeywords.some(keyword => userAgent.includes(keyword)) ||
|
||||
window.innerWidth <= 768 ||
|
||||
(typeof window.orientation !== 'undefined');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect touch support
|
||||
*/
|
||||
detectTouchSupport() {
|
||||
return 'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
navigator.msMaxTouchPoints > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current orientation
|
||||
*/
|
||||
getOrientation() {
|
||||
if (screen.orientation) {
|
||||
return screen.orientation.angle;
|
||||
} else if (window.orientation !== undefined) {
|
||||
return window.orientation;
|
||||
}
|
||||
return window.innerWidth > window.innerHeight ? 90 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup touch optimizations
|
||||
*/
|
||||
setupTouchOptimizations() {
|
||||
if (!this.options.enableTouchOptimizations) return;
|
||||
|
||||
// Add touch-optimized styles
|
||||
this.addTouchStyles();
|
||||
|
||||
// Enhance touch targets
|
||||
this.enhanceTouchTargets();
|
||||
|
||||
// Optimize scroll behavior
|
||||
this.optimizeScrollBehavior();
|
||||
|
||||
// Setup touch feedback
|
||||
this.setupTouchFeedback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add touch-optimized CSS styles
|
||||
*/
|
||||
addTouchStyles() {
|
||||
if (document.getElementById('mobile-touch-styles')) return;
|
||||
|
||||
const styles = `
|
||||
<style id="mobile-touch-styles">
|
||||
@media (max-width: 768px) {
|
||||
/* Touch-friendly button sizes */
|
||||
.btn, button, .filter-chip, .filter-pill {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Larger touch targets for map controls */
|
||||
.leaflet-control-zoom a {
|
||||
width: 44px !important;
|
||||
height: 44px !important;
|
||||
line-height: 44px !important;
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
/* Mobile-optimized map containers */
|
||||
.map-container {
|
||||
height: 60vh !important;
|
||||
min-height: 300px !important;
|
||||
}
|
||||
|
||||
/* Touch-friendly popup styling */
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
margin: 16px 20px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Improved form controls */
|
||||
input, select, textarea {
|
||||
font-size: 16px !important; /* Prevents zoom on iOS */
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Touch-friendly filter panels */
|
||||
.filter-panel {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Mobile navigation improvements */
|
||||
.roadtrip-planner {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.parks-list .park-item {
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 12px;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Swipe indicators */
|
||||
.swipe-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 40px;
|
||||
background: rgba(59, 130, 246, 0.5);
|
||||
border-radius: 2px;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.swipe-indicator.left {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.swipe-indicator.right {
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
/* Extra small screens */
|
||||
.map-container {
|
||||
height: 50vh !important;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch feedback */
|
||||
.touch-feedback {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.touch-feedback::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.3s ease, height 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.touch-feedback.active::after {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
/* Prevent text selection on mobile */
|
||||
.no-select {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Optimize touch scrolling */
|
||||
.touch-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overflow-scrolling: touch;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
document.head.insertAdjacentHTML('beforeend', styles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance touch targets for better accessibility
|
||||
*/
|
||||
enhanceTouchTargets() {
|
||||
const smallTargets = document.querySelectorAll('button, .btn, a, input[type="checkbox"], input[type="radio"]');
|
||||
|
||||
smallTargets.forEach(target => {
|
||||
const rect = target.getBoundingClientRect();
|
||||
|
||||
// If target is smaller than 44px (Apple's recommended minimum), enhance it
|
||||
if (rect.width < 44 || rect.height < 44) {
|
||||
target.classList.add('touch-enhanced');
|
||||
target.style.minWidth = '44px';
|
||||
target.style.minHeight = '44px';
|
||||
target.style.display = 'inline-flex';
|
||||
target.style.alignItems = 'center';
|
||||
target.style.justifyContent = 'center';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize scroll behavior for mobile
|
||||
*/
|
||||
optimizeScrollBehavior() {
|
||||
// Add momentum scrolling to scrollable elements
|
||||
const scrollableElements = document.querySelectorAll('.scrollable, .overflow-auto, .overflow-y-auto');
|
||||
|
||||
scrollableElements.forEach(element => {
|
||||
element.classList.add('touch-scroll');
|
||||
element.style.webkitOverflowScrolling = 'touch';
|
||||
});
|
||||
|
||||
// Prevent body scroll when interacting with maps
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
if (e.target.closest('.leaflet-container')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup touch feedback for interactive elements
|
||||
*/
|
||||
setupTouchFeedback() {
|
||||
const interactiveElements = document.querySelectorAll('button, .btn, .filter-chip, .filter-pill, .park-item');
|
||||
|
||||
interactiveElements.forEach(element => {
|
||||
element.classList.add('touch-feedback');
|
||||
|
||||
element.addEventListener('touchstart', (e) => {
|
||||
element.classList.add('active');
|
||||
|
||||
setTimeout(() => {
|
||||
element.classList.remove('active');
|
||||
}, 300);
|
||||
}, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup swipe gesture support
|
||||
*/
|
||||
setupSwipeGestures() {
|
||||
if (!this.options.enableSwipeGestures) return;
|
||||
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchStartTime = 0;
|
||||
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length === 1) {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
touchStartTime = Date.now();
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', (e) => {
|
||||
if (e.changedTouches.length === 1) {
|
||||
const touchEndX = e.changedTouches[0].clientX;
|
||||
const touchEndY = e.changedTouches[0].clientY;
|
||||
const touchEndTime = Date.now();
|
||||
|
||||
const deltaX = touchEndX - touchStartX;
|
||||
const deltaY = touchEndY - touchStartY;
|
||||
const deltaTime = touchEndTime - touchStartTime;
|
||||
const velocity = Math.abs(deltaX) / deltaTime;
|
||||
|
||||
// Check if this is a swipe gesture
|
||||
if (Math.abs(deltaX) > this.options.swipeThreshold &&
|
||||
Math.abs(deltaY) < Math.abs(deltaX) &&
|
||||
velocity > this.options.swipeVelocityThreshold) {
|
||||
|
||||
const direction = deltaX > 0 ? 'right' : 'left';
|
||||
this.handleSwipeGesture(direction, e.target);
|
||||
}
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle swipe gestures
|
||||
*/
|
||||
handleSwipeGesture(direction, target) {
|
||||
// Handle swipe on filter panels
|
||||
if (target.closest('.filter-panel')) {
|
||||
if (direction === 'down' || direction === 'up') {
|
||||
this.toggleFilterPanel();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle swipe on road trip list
|
||||
if (target.closest('.parks-list')) {
|
||||
if (direction === 'left') {
|
||||
this.showParkActions(target);
|
||||
} else if (direction === 'right') {
|
||||
this.hideParkActions(target);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit custom swipe event
|
||||
const swipeEvent = new CustomEvent('swipe', {
|
||||
detail: { direction, target }
|
||||
});
|
||||
document.dispatchEvent(swipeEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup responsive handling for orientation changes
|
||||
*/
|
||||
setupResponsiveHandling() {
|
||||
if (!this.options.enableResponsiveResize) return;
|
||||
|
||||
// Handle orientation changes
|
||||
window.addEventListener('orientationchange', () => {
|
||||
setTimeout(() => {
|
||||
this.handleOrientationChange();
|
||||
}, this.options.orientationChangeDelay);
|
||||
});
|
||||
|
||||
// Handle window resize
|
||||
let resizeTimeout;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
this.handleWindowResize();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
// Handle viewport changes (for mobile browsers with dynamic toolbars)
|
||||
this.setupViewportHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle orientation change
|
||||
*/
|
||||
handleOrientationChange() {
|
||||
const newOrientation = this.getOrientation();
|
||||
|
||||
if (newOrientation !== this.orientation) {
|
||||
this.orientation = newOrientation;
|
||||
|
||||
// Resize all map instances
|
||||
this.mapInstances.forEach(mapInstance => {
|
||||
if (mapInstance.invalidateSize) {
|
||||
mapInstance.invalidateSize();
|
||||
}
|
||||
});
|
||||
|
||||
// Emit orientation change event
|
||||
const orientationEvent = new CustomEvent('orientationChanged', {
|
||||
detail: { orientation: this.orientation }
|
||||
});
|
||||
document.dispatchEvent(orientationEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle window resize
|
||||
*/
|
||||
handleWindowResize() {
|
||||
// Update mobile detection
|
||||
this.isMobile = this.detectMobileDevice();
|
||||
|
||||
// Resize map instances
|
||||
this.mapInstances.forEach(mapInstance => {
|
||||
if (mapInstance.invalidateSize) {
|
||||
mapInstance.invalidateSize();
|
||||
}
|
||||
});
|
||||
|
||||
// Update touch targets
|
||||
this.enhanceTouchTargets();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup viewport handler for dynamic mobile toolbars
|
||||
*/
|
||||
setupViewportHandler() {
|
||||
// Use visual viewport API if available
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', () => {
|
||||
this.handleViewportChange();
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback for older browsers
|
||||
let lastHeight = window.innerHeight;
|
||||
|
||||
const checkViewportChange = () => {
|
||||
if (Math.abs(window.innerHeight - lastHeight) > 100) {
|
||||
lastHeight = window.innerHeight;
|
||||
this.handleViewportChange();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', checkViewportChange);
|
||||
document.addEventListener('focusin', checkViewportChange);
|
||||
document.addEventListener('focusout', checkViewportChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle viewport changes
|
||||
*/
|
||||
handleViewportChange() {
|
||||
// Adjust map container heights
|
||||
const mapContainers = document.querySelectorAll('.map-container');
|
||||
mapContainers.forEach(container => {
|
||||
const viewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
|
||||
|
||||
if (viewportHeight < 500) {
|
||||
container.style.height = '40vh';
|
||||
} else {
|
||||
container.style.height = ''; // Reset to CSS default
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup battery optimization
|
||||
*/
|
||||
setupBatteryOptimization() {
|
||||
if (!this.options.enableBatteryOptimization) return;
|
||||
|
||||
// Reduce update frequency when battery is low
|
||||
if ('getBattery' in navigator) {
|
||||
navigator.getBattery().then(battery => {
|
||||
const optimizeBattery = () => {
|
||||
if (battery.level < 0.2) { // Battery below 20%
|
||||
this.enableBatterySaveMode();
|
||||
} else {
|
||||
this.disableBatterySaveMode();
|
||||
}
|
||||
};
|
||||
|
||||
battery.addEventListener('levelchange', optimizeBattery);
|
||||
optimizeBattery();
|
||||
});
|
||||
}
|
||||
|
||||
// Reduce activity when page is not visible
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.pauseNonEssentialFeatures();
|
||||
} else {
|
||||
this.resumeNonEssentialFeatures();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable battery save mode
|
||||
*/
|
||||
enableBatterySaveMode() {
|
||||
console.log('Enabling battery save mode');
|
||||
|
||||
// Reduce map update frequency
|
||||
this.mapInstances.forEach(mapInstance => {
|
||||
if (mapInstance.options) {
|
||||
mapInstance.options.updateInterval = 5000; // Increase to 5 seconds
|
||||
}
|
||||
});
|
||||
|
||||
// Disable animations
|
||||
document.body.classList.add('battery-save-mode');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable battery save mode
|
||||
*/
|
||||
disableBatterySaveMode() {
|
||||
console.log('Disabling battery save mode');
|
||||
|
||||
// Restore normal update frequency
|
||||
this.mapInstances.forEach(mapInstance => {
|
||||
if (mapInstance.options) {
|
||||
mapInstance.options.updateInterval = 1000; // Restore to 1 second
|
||||
}
|
||||
});
|
||||
|
||||
// Re-enable animations
|
||||
document.body.classList.remove('battery-save-mode');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause non-essential features
|
||||
*/
|
||||
pauseNonEssentialFeatures() {
|
||||
// Pause location watching
|
||||
if (window.userLocation && window.userLocation.stopWatching) {
|
||||
window.userLocation.stopWatching();
|
||||
}
|
||||
|
||||
// Reduce map updates
|
||||
this.mapInstances.forEach(mapInstance => {
|
||||
if (mapInstance.pauseUpdates) {
|
||||
mapInstance.pauseUpdates();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume non-essential features
|
||||
*/
|
||||
resumeNonEssentialFeatures() {
|
||||
// Resume location watching if it was active
|
||||
if (window.userLocation && window.userLocation.options.watchPosition) {
|
||||
window.userLocation.startWatching();
|
||||
}
|
||||
|
||||
// Resume map updates
|
||||
this.mapInstances.forEach(mapInstance => {
|
||||
if (mapInstance.resumeUpdates) {
|
||||
mapInstance.resumeUpdates();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup accessibility enhancements for mobile
|
||||
*/
|
||||
setupAccessibilityEnhancements() {
|
||||
// Add focus indicators for touch navigation
|
||||
const focusableElements = document.querySelectorAll('button, a, input, select, textarea, [tabindex]');
|
||||
|
||||
focusableElements.forEach(element => {
|
||||
element.addEventListener('focus', () => {
|
||||
element.classList.add('touch-focused');
|
||||
});
|
||||
|
||||
element.addEventListener('blur', () => {
|
||||
element.classList.remove('touch-focused');
|
||||
});
|
||||
});
|
||||
|
||||
// Enhance keyboard navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') {
|
||||
document.body.classList.add('keyboard-navigation');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mousedown', () => {
|
||||
document.body.classList.remove('keyboard-navigation');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event handlers
|
||||
*/
|
||||
bindEventHandlers() {
|
||||
// Handle double-tap to zoom
|
||||
this.setupDoubleTapZoom();
|
||||
|
||||
// Handle long press
|
||||
this.setupLongPress();
|
||||
|
||||
// Handle pinch gestures
|
||||
if (this.options.enablePinchZoom) {
|
||||
this.setupPinchZoom();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup double-tap to zoom
|
||||
*/
|
||||
setupDoubleTapZoom() {
|
||||
let lastTapTime = 0;
|
||||
|
||||
document.addEventListener('touchend', (e) => {
|
||||
const currentTime = Date.now();
|
||||
|
||||
if (currentTime - lastTapTime < 300) {
|
||||
// Double tap detected
|
||||
const target = e.target;
|
||||
if (target.closest('.leaflet-container')) {
|
||||
this.handleDoubleTapZoom(e);
|
||||
}
|
||||
}
|
||||
|
||||
lastTapTime = currentTime;
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle double-tap zoom
|
||||
*/
|
||||
handleDoubleTapZoom(e) {
|
||||
const mapContainer = e.target.closest('.leaflet-container');
|
||||
if (!mapContainer) return;
|
||||
|
||||
// Find associated map instance
|
||||
this.mapInstances.forEach(mapInstance => {
|
||||
if (mapInstance.getContainer() === mapContainer) {
|
||||
const currentZoom = mapInstance.getZoom();
|
||||
const newZoom = currentZoom < mapInstance.getMaxZoom() ? currentZoom + 2 : mapInstance.getMinZoom();
|
||||
|
||||
mapInstance.setZoom(newZoom, {
|
||||
animate: true,
|
||||
duration: 0.3
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup long press detection
|
||||
*/
|
||||
setupLongPress() {
|
||||
let pressTimer;
|
||||
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
pressTimer = setTimeout(() => {
|
||||
this.handleLongPress(e);
|
||||
}, 750); // 750ms for long press
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', () => {
|
||||
clearTimeout(pressTimer);
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchmove', () => {
|
||||
clearTimeout(pressTimer);
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle long press
|
||||
*/
|
||||
handleLongPress(e) {
|
||||
const target = e.target;
|
||||
|
||||
// Emit long press event
|
||||
const longPressEvent = new CustomEvent('longPress', {
|
||||
detail: { target, touches: e.touches }
|
||||
});
|
||||
target.dispatchEvent(longPressEvent);
|
||||
|
||||
// Provide haptic feedback if available
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup pinch zoom for maps
|
||||
*/
|
||||
setupPinchZoom() {
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length === 2) {
|
||||
this.gestureState.isActive = true;
|
||||
this.gestureState.startDistance = this.getDistance(e.touches[0], e.touches[1]);
|
||||
this.gestureState.startCenter = this.getCenter(e.touches[0], e.touches[1]);
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
if (this.gestureState.isActive && e.touches.length === 2) {
|
||||
this.handlePinchZoom(e);
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
document.addEventListener('touchend', () => {
|
||||
this.gestureState.isActive = false;
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pinch zoom gesture
|
||||
*/
|
||||
handlePinchZoom(e) {
|
||||
if (!e.target.closest('.leaflet-container')) return;
|
||||
|
||||
const currentDistance = this.getDistance(e.touches[0], e.touches[1]);
|
||||
const scale = currentDistance / this.gestureState.startDistance;
|
||||
|
||||
// Emit pinch event
|
||||
const pinchEvent = new CustomEvent('pinch', {
|
||||
detail: { scale, center: this.gestureState.startCenter }
|
||||
});
|
||||
e.target.dispatchEvent(pinchEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distance between two touch points
|
||||
*/
|
||||
getDistance(touch1, touch2) {
|
||||
const dx = touch1.clientX - touch2.clientX;
|
||||
const dy = touch1.clientY - touch2.clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get center point between two touches
|
||||
*/
|
||||
getCenter(touch1, touch2) {
|
||||
return {
|
||||
x: (touch1.clientX + touch2.clientX) / 2,
|
||||
y: (touch1.clientY + touch2.clientY) / 2
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register map instance for mobile optimizations
|
||||
*/
|
||||
registerMapInstance(mapInstance) {
|
||||
this.mapInstances.add(mapInstance);
|
||||
|
||||
// Apply mobile-specific map options
|
||||
if (this.isMobile && mapInstance.options) {
|
||||
mapInstance.options.zoomControl = false; // Use custom larger controls
|
||||
mapInstance.options.attributionControl = false; // Save space
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister map instance
|
||||
*/
|
||||
unregisterMapInstance(mapInstance) {
|
||||
this.mapInstances.delete(mapInstance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle filter panel for mobile
|
||||
*/
|
||||
toggleFilterPanel() {
|
||||
const filterPanel = document.querySelector('.filter-panel');
|
||||
if (filterPanel) {
|
||||
filterPanel.classList.toggle('mobile-expanded');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show park actions on swipe
|
||||
*/
|
||||
showParkActions(target) {
|
||||
const parkItem = target.closest('.park-item');
|
||||
if (parkItem) {
|
||||
parkItem.classList.add('actions-visible');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide park actions
|
||||
*/
|
||||
hideParkActions(target) {
|
||||
const parkItem = target.closest('.park-item');
|
||||
if (parkItem) {
|
||||
parkItem.classList.remove('actions-visible');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device is mobile
|
||||
*/
|
||||
isMobileDevice() {
|
||||
return this.isMobile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device supports touch
|
||||
*/
|
||||
isTouchDevice() {
|
||||
return this.isTouch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device info
|
||||
*/
|
||||
getDeviceInfo() {
|
||||
return {
|
||||
isMobile: this.isMobile,
|
||||
isTouch: this.isTouch,
|
||||
orientation: this.orientation,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
pixelRatio: window.devicePixelRatio || 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize mobile touch support
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.mobileTouchSupport = new MobileTouchSupport();
|
||||
|
||||
// Register existing map instances
|
||||
if (window.thrillwikiMap) {
|
||||
window.mobileTouchSupport.registerMapInstance(window.thrillwikiMap);
|
||||
}
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = MobileTouchSupport;
|
||||
} else {
|
||||
window.MobileTouchSupport = MobileTouchSupport;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Only declare parkMap if it doesn't exist
|
||||
window.parkMap = window.parkMap || null;
|
||||
|
||||
function initParkMap(latitude, longitude, name) {
|
||||
const mapContainer = document.getElementById('park-map');
|
||||
|
||||
// Only initialize if container exists and map hasn't been initialized
|
||||
if (mapContainer && !window.parkMap) {
|
||||
const width = mapContainer.offsetWidth;
|
||||
mapContainer.style.height = width + 'px';
|
||||
|
||||
window.parkMap = L.map('park-map').setView([latitude, longitude], 13);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(window.parkMap);
|
||||
|
||||
L.marker([latitude, longitude])
|
||||
.addTo(window.parkMap)
|
||||
.bindPopup(name);
|
||||
|
||||
// Update map size when window is resized
|
||||
window.addEventListener('resize', function() {
|
||||
const width = mapContainer.offsetWidth;
|
||||
mapContainer.style.height = width + 'px';
|
||||
window.parkMap.invalidateSize();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('photoDisplay', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({
|
||||
photos,
|
||||
fullscreenPhoto: null,
|
||||
uploading: false,
|
||||
uploadProgress: 0,
|
||||
error: null,
|
||||
showSuccess: false,
|
||||
|
||||
showFullscreen(photo) {
|
||||
this.fullscreenPhoto = photo;
|
||||
},
|
||||
|
||||
async handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploading = true;
|
||||
this.uploadProgress = 0;
|
||||
this.error = null;
|
||||
this.showSuccess = false;
|
||||
|
||||
const totalFiles = files.length;
|
||||
let completedFiles = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('app_label', contentType.split('.')[0]);
|
||||
formData.append('model', contentType.split('.')[1]);
|
||||
formData.append('object_id', objectId);
|
||||
|
||||
try {
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const photo = await response.json();
|
||||
this.photos.push(photo);
|
||||
completedFiles++;
|
||||
this.uploadProgress = (completedFiles / totalFiles) * 100;
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to upload photo. Please try again.';
|
||||
console.error('Upload error:', err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.uploading = false;
|
||||
event.target.value = ''; // Reset file input
|
||||
|
||||
if (!this.error) {
|
||||
this.showSuccess = true;
|
||||
setTimeout(() => {
|
||||
this.showSuccess = false;
|
||||
}, 3000);
|
||||
}
|
||||
},
|
||||
|
||||
async sharePhoto(photo) {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: photo.caption || 'Shared photo',
|
||||
url: photo.url
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Error sharing:', err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: copy URL to clipboard
|
||||
navigator.clipboard.writeText(photo.url)
|
||||
.then(() => alert('Photo URL copied to clipboard!'))
|
||||
.catch(err => console.error('Error copying to clipboard:', err));
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
@@ -1,774 +0,0 @@
|
||||
/**
|
||||
* ThrillWiki Road Trip Planner - Multi-park Route Planning
|
||||
*
|
||||
* This module provides road trip planning functionality with multi-park selection,
|
||||
* route visualization, distance calculations, and export capabilities
|
||||
*/
|
||||
|
||||
class RoadTripPlanner {
|
||||
constructor(containerId, options = {}) {
|
||||
this.containerId = containerId;
|
||||
this.options = {
|
||||
mapInstance: null,
|
||||
maxParks: 20,
|
||||
enableOptimization: true,
|
||||
enableExport: true,
|
||||
apiEndpoints: {
|
||||
parks: '/api/parks/',
|
||||
route: '/api/roadtrip/route/',
|
||||
optimize: '/api/roadtrip/optimize/',
|
||||
export: '/api/roadtrip/export/'
|
||||
},
|
||||
routeOptions: {
|
||||
color: '#3B82F6',
|
||||
weight: 4,
|
||||
opacity: 0.8
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
this.container = null;
|
||||
this.mapInstance = null;
|
||||
this.selectedParks = [];
|
||||
this.routeLayer = null;
|
||||
this.parkMarkers = new Map();
|
||||
this.routePolyline = null;
|
||||
this.routeData = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the road trip planner
|
||||
*/
|
||||
init() {
|
||||
this.container = document.getElementById(this.containerId);
|
||||
if (!this.container) {
|
||||
console.error(`Road trip container with ID '${this.containerId}' not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setupUI();
|
||||
this.bindEvents();
|
||||
|
||||
// Connect to map instance if provided
|
||||
if (this.options.mapInstance) {
|
||||
this.connectToMap(this.options.mapInstance);
|
||||
}
|
||||
|
||||
this.loadInitialData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the UI components
|
||||
*/
|
||||
setupUI() {
|
||||
const html = `
|
||||
<div class="roadtrip-planner">
|
||||
<div class="roadtrip-header">
|
||||
<h3 class="roadtrip-title">
|
||||
<i class="fas fa-route"></i>
|
||||
Road Trip Planner
|
||||
</h3>
|
||||
<div class="roadtrip-controls">
|
||||
<button id="optimize-route" class="btn btn-secondary btn-sm" disabled>
|
||||
<i class="fas fa-magic"></i> Optimize Route
|
||||
</button>
|
||||
<button id="clear-route" class="btn btn-outline btn-sm" disabled>
|
||||
<i class="fas fa-trash"></i> Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="roadtrip-content">
|
||||
<div class="park-selection">
|
||||
<div class="search-parks">
|
||||
<input type="text" id="park-search"
|
||||
placeholder="Search parks to add..."
|
||||
class="form-input">
|
||||
<div id="park-search-results" class="search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-parks">
|
||||
<h4 class="section-title">Your Route (<span id="park-count">0</span>/${this.options.maxParks})</h4>
|
||||
<div id="parks-list" class="parks-list sortable">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-map-marked-alt"></i>
|
||||
<p>Search and select parks to build your road trip route</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="route-summary" id="route-summary" style="display: none;">
|
||||
<h4 class="section-title">Trip Summary</h4>
|
||||
<div class="summary-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Total Distance:</span>
|
||||
<span id="total-distance" class="stat-value">-</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Driving Time:</span>
|
||||
<span id="total-time" class="stat-value">-</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Parks:</span>
|
||||
<span id="total-parks" class="stat-value">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="export-options">
|
||||
<button id="export-gpx" class="btn btn-outline btn-sm">
|
||||
<i class="fas fa-download"></i> Export GPX
|
||||
</button>
|
||||
<button id="export-kml" class="btn btn-outline btn-sm">
|
||||
<i class="fas fa-download"></i> Export KML
|
||||
</button>
|
||||
<button id="share-route" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-share"></i> Share Route
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.container.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event handlers
|
||||
*/
|
||||
bindEvents() {
|
||||
// Park search
|
||||
const searchInput = document.getElementById('park-search');
|
||||
if (searchInput) {
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
this.searchParks(e.target.value);
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Route controls
|
||||
const optimizeBtn = document.getElementById('optimize-route');
|
||||
if (optimizeBtn) {
|
||||
optimizeBtn.addEventListener('click', () => this.optimizeRoute());
|
||||
}
|
||||
|
||||
const clearBtn = document.getElementById('clear-route');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => this.clearRoute());
|
||||
}
|
||||
|
||||
// Export buttons
|
||||
const exportGpxBtn = document.getElementById('export-gpx');
|
||||
if (exportGpxBtn) {
|
||||
exportGpxBtn.addEventListener('click', () => this.exportRoute('gpx'));
|
||||
}
|
||||
|
||||
const exportKmlBtn = document.getElementById('export-kml');
|
||||
if (exportKmlBtn) {
|
||||
exportKmlBtn.addEventListener('click', () => this.exportRoute('kml'));
|
||||
}
|
||||
|
||||
const shareBtn = document.getElementById('share-route');
|
||||
if (shareBtn) {
|
||||
shareBtn.addEventListener('click', () => this.shareRoute());
|
||||
}
|
||||
|
||||
// Make parks list sortable
|
||||
this.initializeSortable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize drag-and-drop sorting for parks list
|
||||
*/
|
||||
initializeSortable() {
|
||||
const parksList = document.getElementById('parks-list');
|
||||
if (!parksList) return;
|
||||
|
||||
// Simple drag and drop implementation
|
||||
let draggedElement = null;
|
||||
|
||||
parksList.addEventListener('dragstart', (e) => {
|
||||
if (e.target.classList.contains('park-item')) {
|
||||
draggedElement = e.target;
|
||||
e.target.style.opacity = '0.5';
|
||||
}
|
||||
});
|
||||
|
||||
parksList.addEventListener('dragend', (e) => {
|
||||
if (e.target.classList.contains('park-item')) {
|
||||
e.target.style.opacity = '1';
|
||||
draggedElement = null;
|
||||
}
|
||||
});
|
||||
|
||||
parksList.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
parksList.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (draggedElement && e.target.classList.contains('park-item')) {
|
||||
const afterElement = this.getDragAfterElement(parksList, e.clientY);
|
||||
|
||||
if (afterElement == null) {
|
||||
parksList.appendChild(draggedElement);
|
||||
} else {
|
||||
parksList.insertBefore(draggedElement, afterElement);
|
||||
}
|
||||
|
||||
this.reorderParks();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element to insert after during drag and drop
|
||||
*/
|
||||
getDragAfterElement(container, y) {
|
||||
const draggableElements = [...container.querySelectorAll('.park-item:not(.dragging)')];
|
||||
|
||||
return draggableElements.reduce((closest, child) => {
|
||||
const box = child.getBoundingClientRect();
|
||||
const offset = y - box.top - box.height / 2;
|
||||
|
||||
if (offset < 0 && offset > closest.offset) {
|
||||
return { offset: offset, element: child };
|
||||
} else {
|
||||
return closest;
|
||||
}
|
||||
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for parks
|
||||
*/
|
||||
async searchParks(query) {
|
||||
if (!query.trim()) {
|
||||
document.getElementById('park-search-results').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.options.apiEndpoints.parks}?q=${encodeURIComponent(query)}&limit=10`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.displaySearchResults(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to search parks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display park search results
|
||||
*/
|
||||
displaySearchResults(parks) {
|
||||
const resultsContainer = document.getElementById('park-search-results');
|
||||
|
||||
if (parks.length === 0) {
|
||||
resultsContainer.innerHTML = '<div class="no-results">No parks found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = parks
|
||||
.filter(park => !this.isParkSelected(park.id))
|
||||
.map(park => `
|
||||
<div class="search-result-item" data-park-id="${park.id}">
|
||||
<div class="park-info">
|
||||
<div class="park-name">${park.name}</div>
|
||||
<div class="park-location">${park.formatted_location || ''}</div>
|
||||
</div>
|
||||
<button class="add-park-btn" onclick="roadTripPlanner.addPark(${park.id})">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
resultsContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a park is already selected
|
||||
*/
|
||||
isParkSelected(parkId) {
|
||||
return this.selectedParks.some(park => park.id === parkId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a park to the route
|
||||
*/
|
||||
async addPark(parkId) {
|
||||
if (this.selectedParks.length >= this.options.maxParks) {
|
||||
this.showMessage(`Maximum ${this.options.maxParks} parks allowed`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.options.apiEndpoints.parks}${parkId}/`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
const park = data.data;
|
||||
this.selectedParks.push(park);
|
||||
this.updateParksDisplay();
|
||||
this.addParkMarker(park);
|
||||
this.updateRoute();
|
||||
|
||||
// Clear search
|
||||
document.getElementById('park-search').value = '';
|
||||
document.getElementById('park-search-results').innerHTML = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add park:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a park from the route
|
||||
*/
|
||||
removePark(parkId) {
|
||||
const index = this.selectedParks.findIndex(park => park.id === parkId);
|
||||
if (index > -1) {
|
||||
this.selectedParks.splice(index, 1);
|
||||
this.updateParksDisplay();
|
||||
this.removeParkMarker(parkId);
|
||||
this.updateRoute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the parks display
|
||||
*/
|
||||
updateParksDisplay() {
|
||||
const parksList = document.getElementById('parks-list');
|
||||
const parkCount = document.getElementById('park-count');
|
||||
|
||||
parkCount.textContent = this.selectedParks.length;
|
||||
|
||||
if (this.selectedParks.length === 0) {
|
||||
parksList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-map-marked-alt"></i>
|
||||
<p>Search and select parks to build your road trip route</p>
|
||||
</div>
|
||||
`;
|
||||
this.updateControls();
|
||||
return;
|
||||
}
|
||||
|
||||
const html = this.selectedParks.map((park, index) => `
|
||||
<div class="park-item" draggable="true" data-park-id="${park.id}">
|
||||
<div class="park-number">${index + 1}</div>
|
||||
<div class="park-details">
|
||||
<div class="park-name">${park.name}</div>
|
||||
<div class="park-location">${park.formatted_location || ''}</div>
|
||||
${park.distance_from_previous ? `<div class="park-distance">${park.distance_from_previous}</div>` : ''}
|
||||
</div>
|
||||
<div class="park-actions">
|
||||
<button class="btn-icon" onclick="roadTripPlanner.removePark(${park.id})" title="Remove park">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
parksList.innerHTML = html;
|
||||
this.updateControls();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update control buttons state
|
||||
*/
|
||||
updateControls() {
|
||||
const optimizeBtn = document.getElementById('optimize-route');
|
||||
const clearBtn = document.getElementById('clear-route');
|
||||
|
||||
const hasParks = this.selectedParks.length > 0;
|
||||
const canOptimize = this.selectedParks.length > 2;
|
||||
|
||||
if (optimizeBtn) optimizeBtn.disabled = !canOptimize;
|
||||
if (clearBtn) clearBtn.disabled = !hasParks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder parks after drag and drop
|
||||
*/
|
||||
reorderParks() {
|
||||
const parkItems = document.querySelectorAll('.park-item');
|
||||
const newOrder = [];
|
||||
|
||||
parkItems.forEach(item => {
|
||||
const parkId = parseInt(item.dataset.parkId);
|
||||
const park = this.selectedParks.find(p => p.id === parkId);
|
||||
if (park) {
|
||||
newOrder.push(park);
|
||||
}
|
||||
});
|
||||
|
||||
this.selectedParks = newOrder;
|
||||
this.updateRoute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the route visualization
|
||||
*/
|
||||
async updateRoute() {
|
||||
if (this.selectedParks.length < 2) {
|
||||
this.clearRouteVisualization();
|
||||
this.updateRouteSummary(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parkIds = this.selectedParks.map(park => park.id);
|
||||
const response = await fetch(`${this.options.apiEndpoints.route}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({ parks: parkIds })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.routeData = data.data;
|
||||
this.visualizeRoute(data.data);
|
||||
this.updateRouteSummary(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to calculate route:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualize the route on the map
|
||||
*/
|
||||
visualizeRoute(routeData) {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
// Clear existing route
|
||||
this.clearRouteVisualization();
|
||||
|
||||
if (routeData.coordinates) {
|
||||
// Create polyline from coordinates
|
||||
this.routePolyline = L.polyline(routeData.coordinates, this.options.routeOptions);
|
||||
this.routePolyline.addTo(this.mapInstance);
|
||||
|
||||
// Fit map to route bounds
|
||||
if (routeData.coordinates.length > 0) {
|
||||
this.mapInstance.fitBounds(this.routePolyline.getBounds(), { padding: [20, 20] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear route visualization
|
||||
*/
|
||||
clearRouteVisualization() {
|
||||
if (this.routePolyline && this.mapInstance) {
|
||||
this.mapInstance.removeLayer(this.routePolyline);
|
||||
this.routePolyline = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update route summary display
|
||||
*/
|
||||
updateRouteSummary(routeData) {
|
||||
const summarySection = document.getElementById('route-summary');
|
||||
|
||||
if (!routeData || this.selectedParks.length < 2) {
|
||||
summarySection.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
summarySection.style.display = 'block';
|
||||
|
||||
document.getElementById('total-distance').textContent = routeData.total_distance || '-';
|
||||
document.getElementById('total-time').textContent = routeData.total_time || '-';
|
||||
document.getElementById('total-parks').textContent = this.selectedParks.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize the route order
|
||||
*/
|
||||
async optimizeRoute() {
|
||||
if (this.selectedParks.length < 3) return;
|
||||
|
||||
try {
|
||||
const parkIds = this.selectedParks.map(park => park.id);
|
||||
const response = await fetch(`${this.options.apiEndpoints.optimize}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({ parks: parkIds })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
// Reorder parks based on optimization
|
||||
const optimizedOrder = data.data.optimized_order;
|
||||
this.selectedParks = optimizedOrder.map(id =>
|
||||
this.selectedParks.find(park => park.id === id)
|
||||
).filter(Boolean);
|
||||
|
||||
this.updateParksDisplay();
|
||||
this.updateRoute();
|
||||
this.showMessage('Route optimized for shortest distance', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to optimize route:', error);
|
||||
this.showMessage('Failed to optimize route', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the entire route
|
||||
*/
|
||||
clearRoute() {
|
||||
this.selectedParks = [];
|
||||
this.clearAllParkMarkers();
|
||||
this.clearRouteVisualization();
|
||||
this.updateParksDisplay();
|
||||
this.updateRouteSummary(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export route in specified format
|
||||
*/
|
||||
async exportRoute(format) {
|
||||
if (!this.routeData) {
|
||||
this.showMessage('No route to export', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.options.apiEndpoints.export}${format}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
parks: this.selectedParks.map(p => p.id),
|
||||
route_data: this.routeData
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `thrillwiki-roadtrip.${format}`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to export route:', error);
|
||||
this.showMessage('Failed to export route', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share the route
|
||||
*/
|
||||
shareRoute() {
|
||||
if (this.selectedParks.length === 0) {
|
||||
this.showMessage('No route to share', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const parkIds = this.selectedParks.map(p => p.id).join(',');
|
||||
const url = `${window.location.origin}/roadtrip/?parks=${parkIds}`;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: 'ThrillWiki Road Trip',
|
||||
text: `Check out this ${this.selectedParks.length}-park road trip!`,
|
||||
url: url
|
||||
});
|
||||
} else {
|
||||
// Fallback to clipboard
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
this.showMessage('Route URL copied to clipboard', 'success');
|
||||
}).catch(() => {
|
||||
// Manual selection fallback
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = url;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
this.showMessage('Route URL copied to clipboard', 'success');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add park marker to map
|
||||
*/
|
||||
addParkMarker(park) {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
const marker = L.marker([park.latitude, park.longitude], {
|
||||
icon: this.createParkIcon(park)
|
||||
});
|
||||
|
||||
marker.bindPopup(`
|
||||
<div class="park-popup">
|
||||
<h4>${park.name}</h4>
|
||||
<p>${park.formatted_location || ''}</p>
|
||||
<button onclick="roadTripPlanner.removePark(${park.id})" class="btn btn-sm btn-outline">
|
||||
Remove from Route
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
marker.addTo(this.mapInstance);
|
||||
this.parkMarkers.set(park.id, marker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove park marker from map
|
||||
*/
|
||||
removeParkMarker(parkId) {
|
||||
if (this.parkMarkers.has(parkId) && this.mapInstance) {
|
||||
this.mapInstance.removeLayer(this.parkMarkers.get(parkId));
|
||||
this.parkMarkers.delete(parkId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all park markers
|
||||
*/
|
||||
clearAllParkMarkers() {
|
||||
this.parkMarkers.forEach(marker => {
|
||||
if (this.mapInstance) {
|
||||
this.mapInstance.removeLayer(marker);
|
||||
}
|
||||
});
|
||||
this.parkMarkers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom icon for park marker
|
||||
*/
|
||||
createParkIcon(park) {
|
||||
const index = this.selectedParks.findIndex(p => p.id === park.id) + 1;
|
||||
|
||||
return L.divIcon({
|
||||
className: 'roadtrip-park-marker',
|
||||
html: `<div class="park-marker-inner">${index}</div>`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a map instance
|
||||
*/
|
||||
connectToMap(mapInstance) {
|
||||
this.mapInstance = mapInstance;
|
||||
this.options.mapInstance = mapInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load initial data (from URL parameters)
|
||||
*/
|
||||
loadInitialData() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const parkIds = urlParams.get('parks');
|
||||
|
||||
if (parkIds) {
|
||||
const ids = parkIds.split(',').map(id => parseInt(id)).filter(id => !isNaN(id));
|
||||
this.loadParksById(ids);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load parks by IDs
|
||||
*/
|
||||
async loadParksById(parkIds) {
|
||||
try {
|
||||
const promises = parkIds.map(id =>
|
||||
fetch(`${this.options.apiEndpoints.parks}${id}/`)
|
||||
.then(res => res.json())
|
||||
.then(data => data.status === 'success' ? data.data : null)
|
||||
);
|
||||
|
||||
const parks = (await Promise.all(promises)).filter(Boolean);
|
||||
|
||||
this.selectedParks = parks;
|
||||
this.updateParksDisplay();
|
||||
|
||||
// Add markers and update route
|
||||
parks.forEach(park => this.addParkMarker(park));
|
||||
this.updateRoute();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load parks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSRF token for POST requests
|
||||
*/
|
||||
getCsrfToken() {
|
||||
const token = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
return token ? token.value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show message to user
|
||||
*/
|
||||
showMessage(message, type = 'info') {
|
||||
// Create or update message element
|
||||
let messageEl = this.container.querySelector('.roadtrip-message');
|
||||
if (!messageEl) {
|
||||
messageEl = document.createElement('div');
|
||||
messageEl.className = 'roadtrip-message';
|
||||
this.container.insertBefore(messageEl, this.container.firstChild);
|
||||
}
|
||||
|
||||
messageEl.textContent = message;
|
||||
messageEl.className = `roadtrip-message roadtrip-message-${type}`;
|
||||
|
||||
// Auto-hide after delay
|
||||
setTimeout(() => {
|
||||
if (messageEl.parentNode) {
|
||||
messageEl.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize road trip planner
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const roadtripContainer = document.getElementById('roadtrip-planner');
|
||||
if (roadtripContainer) {
|
||||
window.roadTripPlanner = new RoadTripPlanner('roadtrip-planner', {
|
||||
mapInstance: window.thrillwikiMap || null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = RoadTripPlanner;
|
||||
} else {
|
||||
window.RoadTripPlanner = RoadTripPlanner;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
function parkSearch() {
|
||||
return {
|
||||
query: '',
|
||||
results: [],
|
||||
loading: false,
|
||||
selectedId: null,
|
||||
|
||||
async search() {
|
||||
if (!this.query.trim()) {
|
||||
this.results = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await fetch(`/parks/suggest_parks/?search=${encodeURIComponent(this.query)}`);
|
||||
const data = await response.json();
|
||||
this.results = data.results;
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
this.results = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.query = '';
|
||||
this.results = [];
|
||||
this.selectedId = null;
|
||||
},
|
||||
|
||||
selectPark(park) {
|
||||
this.query = park.name;
|
||||
this.selectedId = park.id;
|
||||
this.results = [];
|
||||
|
||||
// Trigger filter update
|
||||
document.getElementById('park-filters').dispatchEvent(new Event('change'));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Theme management script
|
||||
* Prevents flash of wrong theme by setting theme class immediately
|
||||
*/
|
||||
(function() {
|
||||
let theme = localStorage.getItem("theme");
|
||||
if (!theme) {
|
||||
theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
localStorage.setItem("theme", theme);
|
||||
}
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
})();
|
||||
@@ -1,799 +0,0 @@
|
||||
/**
|
||||
* 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 = '<div class="search-results-dropdown">';
|
||||
|
||||
// Group results by type
|
||||
const groupedResults = this.groupResultsByType(data.results);
|
||||
|
||||
Object.keys(groupedResults).forEach(type => {
|
||||
if (groupedResults[type].length > 0) {
|
||||
html += `<div class="search-group">
|
||||
<h4 class="search-group-title">${this.getTypeTitle(type)}</h4>
|
||||
<div class="search-group-items">`;
|
||||
|
||||
groupedResults[type].forEach(result => {
|
||||
html += this.getResultItemHTML(result);
|
||||
});
|
||||
|
||||
html += '</div></div>';
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// Add click handlers
|
||||
this.attachResultClickHandlers(container);
|
||||
},
|
||||
|
||||
getResultItemHTML: function(result) {
|
||||
return `
|
||||
<div class="search-result-item" data-url="${result.url}" data-type="${result.type}">
|
||||
<div class="search-result-icon">
|
||||
<i class="fas fa-${this.getTypeIcon(result.type)}"></i>
|
||||
</div>
|
||||
<div class="search-result-content">
|
||||
<div class="search-result-title">${result.name}</div>
|
||||
<div class="search-result-subtitle">${result.subtitle || ''}</div>
|
||||
</div>
|
||||
${result.image ? `<div class="search-result-image">
|
||||
<img src="${result.image}" alt="${result.name}" loading="lazy">
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
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 `
|
||||
<div class="search-loading">
|
||||
<div class="loading-spinner opacity-100">
|
||||
<i class="fas fa-spinner text-thrill-primary"></i>
|
||||
</div>
|
||||
<span>Searching...</span>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
getNoResultsHTML: function() {
|
||||
return `
|
||||
<div class="search-no-results">
|
||||
<i class="fas fa-search text-neutral-400"></i>
|
||||
<span>No results found</span>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
getErrorHTML: function() {
|
||||
return `
|
||||
<div class="search-error">
|
||||
<i class="fas fa-exclamation-triangle text-red-500"></i>
|
||||
<span>Search error. Please try again.</span>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
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 = `
|
||||
<div class="notification-content">
|
||||
<i class="fas fa-${typeIcons[type] || 'info-circle'} notification-icon"></i>
|
||||
<span class="notification-message">${message}</span>
|
||||
<button class="notification-close" onclick="ThrillWiki.notifications.hide(this.closest('.notification'))">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = '<i class="fas fa-arrow-up"></i>';
|
||||
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;
|
||||
}
|
||||
@@ -172,67 +172,30 @@
|
||||
<!-- Global Toast Container -->
|
||||
<c-toast_container />
|
||||
|
||||
<!-- AlpineJS Components and Stores (Inline) -->
|
||||
<script>
|
||||
// Global Alpine.js stores and components
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// Global Store for App State
|
||||
<!-- AlpineJS Global Configuration (Compliant with HTMX + AlpineJS Only Rule) -->
|
||||
<div x-data="{}" x-init="
|
||||
// Configure HTMX globally
|
||||
htmx.config.globalViewTransitions = true;
|
||||
|
||||
// Initialize Alpine stores
|
||||
Alpine.store('app', {
|
||||
user: null,
|
||||
theme: localStorage.getItem('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);
|
||||
}
|
||||
notifications: []
|
||||
});
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
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);
|
||||
setTimeout(() => this.hide(id), duration);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
hide(id) {
|
||||
const toast = this.toasts.find(t => t.id === id);
|
||||
if (toast) {
|
||||
@@ -241,278 +204,9 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// 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;
|
||||
}
|
||||
}));
|
||||
|
||||
// Search Component - HTMX-based (NO FETCH API)
|
||||
Alpine.data('searchComponent', () => ({
|
||||
query: '',
|
||||
loading: false,
|
||||
showResults: false,
|
||||
|
||||
init() {
|
||||
// Listen for HTMX events
|
||||
this.$el.addEventListener('htmx:beforeRequest', () => {
|
||||
this.loading = true;
|
||||
});
|
||||
|
||||
this.$el.addEventListener('htmx:afterRequest', () => {
|
||||
this.loading = false;
|
||||
});
|
||||
|
||||
this.$el.addEventListener('htmx:afterSettle', () => {
|
||||
const resultsContainer = document.getElementById('search-results');
|
||||
this.showResults = resultsContainer && resultsContainer.children.length > 0;
|
||||
});
|
||||
},
|
||||
|
||||
handleInput() {
|
||||
if (this.query.length < 2) {
|
||||
this.showResults = false;
|
||||
const resultsContainer = document.getElementById('search-results');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.innerHTML = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
// HTMX will handle the actual search via hx-trigger
|
||||
},
|
||||
|
||||
selectResult(url) {
|
||||
window.location.href = url;
|
||||
this.showResults = false;
|
||||
this.query = '';
|
||||
},
|
||||
|
||||
clearSearch() {
|
||||
this.query = '';
|
||||
this.showResults = false;
|
||||
const resultsContainer = document.getElementById('search-results');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// 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;
|
||||
|
||||
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;
|
||||
}
|
||||
}));
|
||||
|
||||
// Auth Modal Component
|
||||
Alpine.data('authModal', (defaultMode = 'login') => ({
|
||||
open: false,
|
||||
mode: defaultMode,
|
||||
showPassword: false,
|
||||
socialProviders: [
|
||||
{id: 'google', name: 'Google', auth_url: '/accounts/google/login/'},
|
||||
{id: 'discord', name: 'Discord', auth_url: '/accounts/discord/login/'}
|
||||
],
|
||||
|
||||
loginForm: {
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
loginLoading: false,
|
||||
loginError: '',
|
||||
|
||||
registerForm: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
username: '',
|
||||
password1: '',
|
||||
password2: ''
|
||||
},
|
||||
registerLoading: false,
|
||||
registerError: '',
|
||||
|
||||
init() {
|
||||
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;
|
||||
},
|
||||
|
||||
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 || '';
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
" style="display: none;"></div>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -41,9 +41,9 @@
|
||||
<div class="hidden lg:flex items-center space-x-8">
|
||||
<!-- Main Navigation Links -->
|
||||
<div class="flex items-center space-x-6">
|
||||
<a href="{% url 'parks:list' %}"
|
||||
<a href="{% url 'parks:park_list' %}"
|
||||
class="nav-link group relative"
|
||||
hx-get="{% url 'parks:list' %}"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#main-content"
|
||||
hx-swap="innerHTML transition:true">
|
||||
<i class="fas fa-map-marked-alt mr-2 text-thrill-primary"></i>
|
||||
@@ -360,14 +360,14 @@
|
||||
|
||||
<!-- Mobile Navigation Links -->
|
||||
<div class="space-y-4">
|
||||
<a href="{% url 'parks:list' %}"
|
||||
<a href="{% url 'parks:park_list' %}"
|
||||
class="flex items-center p-3 rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-700/50 transition-colors"
|
||||
@click="isOpen = false">
|
||||
<i class="fas fa-map-marked-alt mr-3 text-thrill-primary"></i>
|
||||
<span class="font-medium">Parks</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'rides:list' %}"
|
||||
<a href="{% url 'rides:ride_list' %}"
|
||||
class="flex items-center p-3 rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-700/50 transition-colors"
|
||||
@click="isOpen = false">
|
||||
<i class="fas fa-rocket mr-3 text-thrill-secondary"></i>
|
||||
|
||||
@@ -333,47 +333,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Enhanced JavaScript for Interactions -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Enable HTMX view transitions globally
|
||||
htmx.config.globalViewTransitions = true;
|
||||
|
||||
// Add staggered animations to elements
|
||||
const animatedElements = document.querySelectorAll('.slide-in-up');
|
||||
animatedElements.forEach((el, index) => {
|
||||
el.style.animationDelay = `${index * 0.1}s`;
|
||||
});
|
||||
|
||||
// Parallax effect for hero background elements
|
||||
window.addEventListener('scroll', () => {
|
||||
const scrolled = window.pageYOffset;
|
||||
const parallaxElements = document.querySelectorAll('.hero .absolute');
|
||||
|
||||
parallaxElements.forEach((el, index) => {
|
||||
const speed = 0.5 + (index * 0.1);
|
||||
el.style.transform = `translateY(${scrolled * speed}px)`;
|
||||
});
|
||||
});
|
||||
|
||||
// Intersection Observer for reveal animations
|
||||
const observerOptions = {
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px 0px -50px 0px'
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('fade-in');
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
// Observe all cards for reveal animations
|
||||
document.querySelectorAll('.card, .card-feature, .card-park, .card-ride').forEach(card => {
|
||||
observer.observe(card);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<!-- HTMX + AlpineJS Implementation (NO Custom JavaScript) -->
|
||||
<div x-data="homePageAnimations" x-init="init()">
|
||||
<!-- Animation triggers handled by AlpineJS -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -197,310 +197,10 @@
|
||||
<!-- Leaflet MarkerCluster JS -->
|
||||
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
|
||||
|
||||
<script>
|
||||
// Map initialization and management
|
||||
class ThrillWikiMap {
|
||||
constructor(containerId, options = {}) {
|
||||
this.containerId = containerId;
|
||||
this.options = {
|
||||
center: [39.8283, -98.5795], // Center of USA
|
||||
zoom: 4,
|
||||
enableClustering: true,
|
||||
...options
|
||||
};
|
||||
this.map = null;
|
||||
this.markers = new L.MarkerClusterGroup();
|
||||
this.currentData = [];
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Initialize the map
|
||||
this.map = L.map(this.containerId, {
|
||||
center: this.options.center,
|
||||
zoom: this.options.zoom,
|
||||
zoomControl: false
|
||||
});
|
||||
|
||||
// Add custom zoom control
|
||||
L.control.zoom({
|
||||
position: 'bottomright'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Add tile layers with dark mode support
|
||||
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
className: 'map-tiles'
|
||||
});
|
||||
|
||||
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors, © CARTO',
|
||||
className: 'map-tiles-dark'
|
||||
});
|
||||
|
||||
// Set initial tiles based on theme
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
darkTiles.addTo(this.map);
|
||||
} else {
|
||||
lightTiles.addTo(this.map);
|
||||
}
|
||||
|
||||
// Listen for theme changes
|
||||
this.observeThemeChanges(lightTiles, darkTiles);
|
||||
|
||||
// Add markers cluster group
|
||||
this.map.addLayer(this.markers);
|
||||
|
||||
// Bind map events
|
||||
this.bindEvents();
|
||||
|
||||
// Load initial data
|
||||
this.loadMapData();
|
||||
}
|
||||
|
||||
observeThemeChanges(lightTiles, darkTiles) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
this.map.removeLayer(lightTiles);
|
||||
this.map.addLayer(darkTiles);
|
||||
} else {
|
||||
this.map.removeLayer(darkTiles);
|
||||
this.map.addLayer(lightTiles);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Update map when bounds change
|
||||
this.map.on('moveend zoomend', () => {
|
||||
this.updateMapBounds();
|
||||
});
|
||||
|
||||
// Handle filter form changes
|
||||
document.getElementById('map-filters').addEventListener('htmx:afterRequest', (event) => {
|
||||
if (event.detail.successful) {
|
||||
this.loadMapData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadMapData() {
|
||||
try {
|
||||
document.getElementById('map-loading').style.display = 'flex';
|
||||
|
||||
const formData = new FormData(document.getElementById('map-filters'));
|
||||
const queryParams = {};
|
||||
|
||||
// Add form data to params
|
||||
for (let [key, value] of formData.entries()) {
|
||||
queryParams[key] = value;
|
||||
}
|
||||
|
||||
// Add map bounds
|
||||
const bounds = this.map.getBounds();
|
||||
queryParams.north = bounds.getNorth();
|
||||
queryParams.south = bounds.getSouth();
|
||||
queryParams.east = bounds.getEast();
|
||||
queryParams.west = bounds.getWest();
|
||||
queryParams.zoom = this.map.getZoom();
|
||||
|
||||
// Create temporary form for HTMX request
|
||||
const tempForm = document.createElement('form');
|
||||
tempForm.setAttribute('hx-get', '{{ map_api_urls.locations }}');
|
||||
tempForm.setAttribute('hx-vals', JSON.stringify(queryParams));
|
||||
tempForm.setAttribute('hx-trigger', 'submit');
|
||||
tempForm.setAttribute('hx-swap', 'none');
|
||||
|
||||
tempForm.addEventListener('htmx:afterRequest', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.detail.xhr.responseText);
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.updateMarkers(data.data);
|
||||
} else {
|
||||
console.error('Map data error:', data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load map data:', error);
|
||||
} finally {
|
||||
document.getElementById('map-loading').style.display = 'none';
|
||||
document.body.removeChild(tempForm);
|
||||
}
|
||||
});
|
||||
|
||||
tempForm.addEventListener('htmx:error', (event) => {
|
||||
console.error('Failed to load map data:', event.detail.error);
|
||||
document.getElementById('map-loading').style.display = 'none';
|
||||
document.body.removeChild(tempForm);
|
||||
});
|
||||
|
||||
document.body.appendChild(tempForm);
|
||||
htmx.trigger(tempForm, 'submit');
|
||||
} catch (error) {
|
||||
console.error('Failed to load map data:', error);
|
||||
document.getElementById('map-loading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updateMarkers(data) {
|
||||
// Clear existing markers
|
||||
this.markers.clearLayers();
|
||||
|
||||
// Add location markers
|
||||
if (data.locations) {
|
||||
data.locations.forEach(location => {
|
||||
this.addLocationMarker(location);
|
||||
});
|
||||
}
|
||||
|
||||
// Add cluster markers
|
||||
if (data.clusters) {
|
||||
data.clusters.forEach(cluster => {
|
||||
this.addClusterMarker(cluster);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addLocationMarker(location) {
|
||||
const icon = this.getLocationIcon(location.type);
|
||||
const marker = L.marker([location.latitude, location.longitude], { icon });
|
||||
|
||||
// Create popup content
|
||||
const popupContent = this.createPopupContent(location);
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
// Add click handler for detailed view
|
||||
marker.on('click', () => {
|
||||
this.showLocationDetails(location.type, location.id);
|
||||
});
|
||||
|
||||
this.markers.addLayer(marker);
|
||||
}
|
||||
|
||||
addClusterMarker(cluster) {
|
||||
const marker = L.marker([cluster.latitude, cluster.longitude], {
|
||||
icon: L.divIcon({
|
||||
className: 'cluster-marker',
|
||||
html: `<div class="cluster-marker-inner">${cluster.count}</div>`,
|
||||
iconSize: [40, 40]
|
||||
})
|
||||
});
|
||||
|
||||
marker.bindPopup(`${cluster.count} locations in this area`);
|
||||
this.markers.addLayer(marker);
|
||||
}
|
||||
|
||||
getLocationIcon(type) {
|
||||
const iconMap = {
|
||||
'park': '🎢',
|
||||
'ride': '🎠',
|
||||
'company': '🏢',
|
||||
'generic': '📍'
|
||||
};
|
||||
|
||||
return L.divIcon({
|
||||
className: 'location-marker',
|
||||
html: `<div class="location-marker-inner">${iconMap[type] || '📍'}</div>`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15]
|
||||
});
|
||||
}
|
||||
|
||||
createPopupContent(location) {
|
||||
return `
|
||||
<div class="location-info-popup">
|
||||
<h3>${location.name}</h3>
|
||||
${location.formatted_location ? `<p><i class="fas fa-map-marker-alt mr-1"></i>${location.formatted_location}</p>` : ''}
|
||||
${location.operator ? `<p><i class="fas fa-building mr-1"></i>${location.operator}</p>` : ''}
|
||||
${location.ride_count ? `<p><i class="fas fa-rocket mr-1"></i>${location.ride_count} rides</p>` : ''}
|
||||
<div class="mt-2">
|
||||
<button onclick="thrillwikiMap.showLocationDetails('${location.type}', ${location.id})"
|
||||
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
|
||||
View Details
|
||||
</button>
|
||||
<!-- AlpineJS Map Component (HTMX + AlpineJS Only) -->
|
||||
<div x-data="universalMap" x-init="initMap()" style="display: none;">
|
||||
<!-- Map functionality handled by AlpineJS + HTMX -->
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
showLocationDetails(type, id) {
|
||||
// Create temporary form for HTMX request
|
||||
const tempForm = document.createElement('form');
|
||||
tempForm.setAttribute('hx-get', `{% url 'maps:htmx_location_detail' 'TYPE' 0 %}`.replace('TYPE', type).replace('0', id));
|
||||
tempForm.setAttribute('hx-target', '#location-modal');
|
||||
tempForm.setAttribute('hx-swap', 'innerHTML');
|
||||
tempForm.setAttribute('hx-trigger', 'submit');
|
||||
|
||||
tempForm.addEventListener('htmx:afterRequest', (event) => {
|
||||
if (event.detail.successful) {
|
||||
document.getElementById('location-modal').classList.remove('hidden');
|
||||
}
|
||||
document.body.removeChild(tempForm);
|
||||
});
|
||||
|
||||
tempForm.addEventListener('htmx:error', (event) => {
|
||||
console.error('Failed to load location details:', event.detail.error);
|
||||
document.body.removeChild(tempForm);
|
||||
});
|
||||
|
||||
document.body.appendChild(tempForm);
|
||||
htmx.trigger(tempForm, 'submit');
|
||||
}
|
||||
|
||||
updateMapBounds() {
|
||||
// This could trigger an HTMX request to update data based on new bounds
|
||||
// For now, we'll just reload data when the map moves significantly
|
||||
clearTimeout(this.boundsUpdateTimeout);
|
||||
this.boundsUpdateTimeout = setTimeout(() => {
|
||||
this.loadMapData();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize map when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.thrillwikiMap = new ThrillWikiMap('map-container', {
|
||||
{% if initial_bounds %}
|
||||
center: [{{ initial_bounds.north|add:initial_bounds.south|floatformat:6|div:2 }}, {{ initial_bounds.east|add:initial_bounds.west|floatformat:6|div:2 }}],
|
||||
{% endif %}
|
||||
enableClustering: {{ enable_clustering|yesno:"true,false" }}
|
||||
});
|
||||
|
||||
// Handle filter pill toggles
|
||||
document.querySelectorAll('.filter-pill').forEach(pill => {
|
||||
const checkbox = pill.querySelector('input[type="checkbox"]');
|
||||
|
||||
// Set initial state
|
||||
if (checkbox.checked) {
|
||||
pill.classList.add('active');
|
||||
}
|
||||
|
||||
pill.addEventListener('click', () => {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
pill.classList.toggle('active', checkbox.checked);
|
||||
|
||||
// Trigger form change
|
||||
document.getElementById('map-filters').dispatchEvent(new Event('change'));
|
||||
});
|
||||
});
|
||||
|
||||
// Close modal handler
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'location-modal') {
|
||||
document.getElementById('location-modal').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.cluster-marker {
|
||||
|
||||
@@ -180,8 +180,23 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- AlpineJS Moderation Dashboard Component (HTMX + AlpineJS Only) -->
|
||||
<div x-data="{
|
||||
showLoading: false,
|
||||
errorMessage: null,
|
||||
showError(message) {
|
||||
this.errorMessage = message;
|
||||
}
|
||||
}"
|
||||
@htmx:before-request="showLoading = true"
|
||||
@htmx:after-request="showLoading = false"
|
||||
@htmx:response-error="showError('Failed to load content')"
|
||||
style="display: none;">
|
||||
<!-- Dashboard functionality handled by AlpineJS + HTMX -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// HTMX Configuration and Enhancements
|
||||
// HTMX Configuration
|
||||
document.body.addEventListener('htmx:configRequest', function(evt) {
|
||||
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
|
||||
});
|
||||
|
||||
@@ -288,114 +288,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AlpineJS State Management -->
|
||||
<script>
|
||||
{# Enhanced Mobile-First AlpineJS State Management #}
|
||||
function parkListState() {
|
||||
return {
|
||||
showFilters: window.innerWidth >= 1024, // Show on desktop by default
|
||||
<!-- AlpineJS Component Definition (HTMX + AlpineJS Only) -->
|
||||
<div x-data="{
|
||||
showFilters: window.innerWidth >= 1024,
|
||||
viewMode: '{{ view_mode }}',
|
||||
searchQuery: '{{ search_query }}',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
init() {
|
||||
// Handle responsive filter visibility with better mobile UX
|
||||
this.handleResize();
|
||||
window.addEventListener('resize', this.debounce(() => this.handleResize(), 250));
|
||||
|
||||
// Enhanced HTMX events with better mobile feedback
|
||||
document.addEventListener('htmx:beforeRequest', () => {
|
||||
this.setLoading(true);
|
||||
this.error = null;
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', (event) => {
|
||||
this.setLoading(false);
|
||||
// Scroll to top of results on mobile after filter changes
|
||||
if (window.innerWidth < 768 && event.detail.target?.id === 'park-results') {
|
||||
this.scrollToResults();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:responseError', () => {
|
||||
this.setLoading(false);
|
||||
this.showError('Failed to load results. Please check your connection and try again.');
|
||||
});
|
||||
|
||||
// Handle mobile viewport changes (orientation, virtual keyboard)
|
||||
this.handleMobileViewport();
|
||||
},
|
||||
|
||||
handleResize() {
|
||||
if (window.innerWidth >= 1024) {
|
||||
this.showFilters = true;
|
||||
}
|
||||
// Auto-hide filters on mobile after interaction for better UX
|
||||
// Keep current state but could add auto-hide logic here
|
||||
},
|
||||
|
||||
handleMobileViewport() {
|
||||
// Handle mobile viewport changes for better UX
|
||||
if ('visualViewport' in window) {
|
||||
window.visualViewport.addEventListener('resize', () => {
|
||||
// Handle virtual keyboard appearance/disappearance
|
||||
document.documentElement.style.setProperty(
|
||||
'--viewport-height',
|
||||
`${window.visualViewport.height}px`
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
scrollToResults() {
|
||||
// Smooth scroll to results on mobile for better UX
|
||||
const resultsElement = document.getElementById('park-results');
|
||||
if (resultsElement) {
|
||||
resultsElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setLoading(loading) {
|
||||
this.isLoading = loading;
|
||||
// Disable form interactions while loading for better UX
|
||||
const formElements = document.querySelectorAll('select, input, button');
|
||||
formElements.forEach(el => {
|
||||
el.disabled = loading;
|
||||
});
|
||||
},
|
||||
|
||||
showError(message) {
|
||||
this.error = message;
|
||||
// Auto-clear error after 5 seconds
|
||||
setTimeout(() => {
|
||||
this.error = null;
|
||||
}, 5000);
|
||||
console.error(message);
|
||||
},
|
||||
|
||||
clearAllFilters() {
|
||||
// Add loading state for better UX
|
||||
this.setLoading(true);
|
||||
window.location.href = '{% url "parks:park_list" %}';
|
||||
},
|
||||
|
||||
// Utility function for better performance
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
window.location.href = '{% url \"parks:park_list\" %}';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
}"
|
||||
@htmx:before-request="isLoading = true; error = null"
|
||||
@htmx:after-request="isLoading = false"
|
||||
@htmx:response-error="isLoading = false; error = 'Failed to load results'"
|
||||
style="display: none;">
|
||||
<!-- Park list functionality handled by AlpineJS + HTMX -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -306,517 +306,8 @@
|
||||
<!-- Sortable JS for drag & drop -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Road Trip Planner class
|
||||
class TripPlanner {
|
||||
constructor() {
|
||||
this.map = null;
|
||||
this.tripParks = [];
|
||||
this.allParks = [];
|
||||
this.parkMarkers = {};
|
||||
this.routeControl = null;
|
||||
this.showingAllParks = false;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.initMap();
|
||||
this.loadAllParks();
|
||||
this.initDragDrop();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
initMap() {
|
||||
// Initialize the map
|
||||
this.map = L.map('map-container', {
|
||||
center: [39.8283, -98.5795],
|
||||
zoom: 4,
|
||||
zoomControl: false
|
||||
});
|
||||
|
||||
// Add custom zoom control
|
||||
L.control.zoom({
|
||||
position: 'bottomright'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Add tile layers with dark mode support
|
||||
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
});
|
||||
|
||||
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors, © CARTO'
|
||||
});
|
||||
|
||||
// Set initial tiles based on theme
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
darkTiles.addTo(this.map);
|
||||
} else {
|
||||
lightTiles.addTo(this.map);
|
||||
}
|
||||
|
||||
// Listen for theme changes
|
||||
this.observeThemeChanges(lightTiles, darkTiles);
|
||||
}
|
||||
|
||||
observeThemeChanges(lightTiles, darkTiles) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
this.map.removeLayer(lightTiles);
|
||||
this.map.addLayer(darkTiles);
|
||||
} else {
|
||||
this.map.removeLayer(darkTiles);
|
||||
this.map.addLayer(lightTiles);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
}
|
||||
|
||||
loadAllParks() {
|
||||
// Create temporary form for HTMX request
|
||||
const tempForm = document.createElement('form');
|
||||
tempForm.setAttribute('hx-get', '{{ map_api_urls.locations }}');
|
||||
tempForm.setAttribute('hx-vals', JSON.stringify({types: 'park', limit: 1000}));
|
||||
tempForm.setAttribute('hx-trigger', 'submit');
|
||||
tempForm.setAttribute('hx-swap', 'none');
|
||||
|
||||
tempForm.addEventListener('htmx:afterRequest', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.detail.xhr.responseText);
|
||||
if (data.status === 'success' && data.data.locations) {
|
||||
this.allParks = data.data.locations;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load parks:', error);
|
||||
}
|
||||
document.body.removeChild(tempForm);
|
||||
});
|
||||
|
||||
document.body.appendChild(tempForm);
|
||||
htmx.trigger(tempForm, 'submit');
|
||||
}
|
||||
|
||||
initDragDrop() {
|
||||
// Make trip parks sortable
|
||||
new Sortable(document.getElementById('trip-parks'), {
|
||||
animation: 150,
|
||||
ghostClass: 'drag-over',
|
||||
onEnd: (evt) => {
|
||||
this.reorderTripParks(evt.oldIndex, evt.newIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Handle park search results
|
||||
document.addEventListener('htmx:afterRequest', (event) => {
|
||||
if (event.target.id === 'park-search-results') {
|
||||
this.handleSearchResults();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleSearchResults() {
|
||||
const results = document.getElementById('park-search-results');
|
||||
if (results.children.length > 0) {
|
||||
results.classList.remove('hidden');
|
||||
} else {
|
||||
results.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
addParkToTrip(parkData) {
|
||||
// Check if park already in trip
|
||||
if (this.tripParks.find(p => p.id === parkData.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tripParks.push(parkData);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
// Hide search results
|
||||
document.getElementById('park-search-results').classList.add('hidden');
|
||||
document.getElementById('park-search').value = '';
|
||||
}
|
||||
|
||||
removeParkFromTrip(parkId) {
|
||||
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateTripDisplay() {
|
||||
const container = document.getElementById('trip-parks');
|
||||
const emptyState = document.getElementById('empty-trip');
|
||||
|
||||
if (this.tripParks.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
// Clear existing parks (except empty state)
|
||||
Array.from(container.children).forEach(child => {
|
||||
if (child.id !== 'empty-trip') {
|
||||
child.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Add trip parks
|
||||
this.tripParks.forEach((park, index) => {
|
||||
const parkElement = this.createTripParkElement(park, index);
|
||||
container.appendChild(parkElement);
|
||||
});
|
||||
}
|
||||
|
||||
createTripParkElement(park, index) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'park-card draggable-item';
|
||||
div.innerHTML = `
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold">
|
||||
${index + 1}
|
||||
<!-- AlpineJS Trip Planner Component (HTMX + AlpineJS Only) -->
|
||||
<div x-data="tripPlanner" x-init="initMap()" style="display: none;">
|
||||
<!-- Trip planner functionality handled by AlpineJS + HTMX -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
${park.name}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||
${park.formatted_location || 'Location not specified'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
|
||||
class="text-red-500 hover:text-red-700 p-1">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<i class="fas fa-grip-vertical text-gray-400 cursor-grab"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return div;
|
||||
}
|
||||
|
||||
updateTripMarkers() {
|
||||
// Clear existing trip markers
|
||||
Object.values(this.parkMarkers).forEach(marker => {
|
||||
this.map.removeLayer(marker);
|
||||
});
|
||||
this.parkMarkers = {};
|
||||
|
||||
// Add markers for trip parks
|
||||
this.tripParks.forEach((park, index) => {
|
||||
const marker = this.createTripMarker(park, index);
|
||||
this.parkMarkers[park.id] = marker;
|
||||
marker.addTo(this.map);
|
||||
});
|
||||
|
||||
// Fit map to show all trip parks
|
||||
if (this.tripParks.length > 0) {
|
||||
this.fitRoute();
|
||||
}
|
||||
}
|
||||
|
||||
createTripMarker(park, index) {
|
||||
let markerClass = 'waypoint-stop';
|
||||
if (index === 0) markerClass = 'waypoint-start';
|
||||
if (index === this.tripParks.length - 1 && this.tripParks.length > 1) markerClass = 'waypoint-end';
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: `waypoint-marker ${markerClass}`,
|
||||
html: `<div class="waypoint-marker-inner">${index + 1}</div>`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15]
|
||||
});
|
||||
|
||||
const marker = L.marker([park.latitude, park.longitude], { icon });
|
||||
|
||||
const popupContent = `
|
||||
<div class="text-center">
|
||||
<h3 class="font-semibold mb-2">${park.name}</h3>
|
||||
<div class="text-sm text-gray-600 mb-2">Stop ${index + 1}</div>
|
||||
${park.ride_count ? `<div class="text-sm text-gray-600 mb-2">${park.ride_count} rides</div>` : ''}
|
||||
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
|
||||
class="px-3 py-1 text-sm text-red-600 border border-red-600 rounded hover:bg-red-50">
|
||||
Remove from Trip
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
return marker;
|
||||
}
|
||||
|
||||
reorderTripParks(oldIndex, newIndex) {
|
||||
const park = this.tripParks.splice(oldIndex, 1)[0];
|
||||
this.tripParks.splice(newIndex, 0, park);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
|
||||
// Clear route to force recalculation
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
optimizeRoute() {
|
||||
if (this.tripParks.length < 2) return;
|
||||
|
||||
const parkIds = this.tripParks.map(p => p.id);
|
||||
|
||||
// Create temporary form for HTMX request
|
||||
const tempForm = document.createElement('form');
|
||||
tempForm.setAttribute('hx-post', '{% url "parks:htmx_optimize_route" %}');
|
||||
tempForm.setAttribute('hx-vals', JSON.stringify({ park_ids: parkIds }));
|
||||
tempForm.setAttribute('hx-trigger', 'submit');
|
||||
tempForm.setAttribute('hx-swap', 'none');
|
||||
|
||||
// Add CSRF token
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrfmiddlewaretoken';
|
||||
csrfInput.value = '{{ csrf_token }}';
|
||||
tempForm.appendChild(csrfInput);
|
||||
|
||||
tempForm.addEventListener('htmx:afterRequest', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.detail.xhr.responseText);
|
||||
|
||||
if (data.status === 'success' && data.optimized_order) {
|
||||
// Reorder parks based on optimization
|
||||
const optimizedParks = data.optimized_order.map(id =>
|
||||
this.tripParks.find(p => p.id === id)
|
||||
).filter(Boolean);
|
||||
|
||||
this.tripParks = optimizedParks;
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Route optimization failed:', error);
|
||||
}
|
||||
document.body.removeChild(tempForm);
|
||||
});
|
||||
|
||||
document.body.appendChild(tempForm);
|
||||
htmx.trigger(tempForm, 'submit');
|
||||
}
|
||||
|
||||
calculateRoute() {
|
||||
if (this.tripParks.length < 2) return;
|
||||
|
||||
// Remove existing route
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
}
|
||||
|
||||
const waypoints = this.tripParks.map(park =>
|
||||
L.latLng(park.latitude, park.longitude)
|
||||
);
|
||||
|
||||
this.routeControl = L.Routing.control({
|
||||
waypoints: waypoints,
|
||||
routeWhileDragging: false,
|
||||
addWaypoints: false,
|
||||
createMarker: () => null, // Don't create default markers
|
||||
lineOptions: {
|
||||
styles: [{ color: '#3b82f6', weight: 4, opacity: 0.7 }]
|
||||
}
|
||||
}).addTo(this.map);
|
||||
|
||||
this.routeControl.on('routesfound', (e) => {
|
||||
const route = e.routes[0];
|
||||
this.updateTripSummary(route);
|
||||
});
|
||||
}
|
||||
|
||||
updateTripSummary(route) {
|
||||
if (!route) return;
|
||||
|
||||
const totalDistance = (route.summary.totalDistance / 1609.34).toFixed(1); // Convert to miles
|
||||
const totalTime = this.formatDuration(route.summary.totalTime);
|
||||
const totalRides = this.tripParks.reduce((sum, park) => sum + (park.ride_count || 0), 0);
|
||||
|
||||
document.getElementById('total-distance').textContent = totalDistance;
|
||||
document.getElementById('total-time').textContent = totalTime;
|
||||
document.getElementById('total-parks').textContent = this.tripParks.length;
|
||||
document.getElementById('total-rides').textContent = totalRides;
|
||||
|
||||
document.getElementById('trip-summary').classList.remove('hidden');
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
fitRoute() {
|
||||
if (this.tripParks.length === 0) return;
|
||||
|
||||
const group = new L.featureGroup(Object.values(this.parkMarkers));
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
|
||||
toggleAllParks() {
|
||||
// Implementation for showing/hiding all parks on the map
|
||||
const button = document.getElementById('toggle-parks');
|
||||
const icon = button.querySelector('i');
|
||||
|
||||
if (this.showingAllParks) {
|
||||
// Hide all parks
|
||||
this.showingAllParks = false;
|
||||
icon.className = 'mr-1 fas fa-eye';
|
||||
button.innerHTML = icon.outerHTML + 'Show All Parks';
|
||||
} else {
|
||||
// Show all parks
|
||||
this.showingAllParks = true;
|
||||
icon.className = 'mr-1 fas fa-eye-slash';
|
||||
button.innerHTML = icon.outerHTML + 'Hide All Parks';
|
||||
this.displayAllParks();
|
||||
}
|
||||
}
|
||||
|
||||
displayAllParks() {
|
||||
// Add markers for all parks (implementation depends on requirements)
|
||||
this.allParks.forEach(park => {
|
||||
if (!this.parkMarkers[park.id]) {
|
||||
const marker = L.marker([park.latitude, park.longitude], {
|
||||
icon: L.divIcon({
|
||||
className: 'location-marker location-marker-park',
|
||||
html: '<div class="location-marker-inner">🎢</div>',
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10]
|
||||
})
|
||||
});
|
||||
|
||||
marker.bindPopup(`
|
||||
<div class="text-center">
|
||||
<h3 class="font-semibold mb-2">${park.name}</h3>
|
||||
<button onclick="tripPlanner.addParkToTrip(${JSON.stringify(park).replace(/"/g, '"')})"
|
||||
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
|
||||
Add to Trip
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
marker.addTo(this.map);
|
||||
this.parkMarkers[`all_${park.id}`] = marker;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateButtons() {
|
||||
const optimizeBtn = document.getElementById('optimize-route');
|
||||
const calculateBtn = document.getElementById('calculate-route');
|
||||
|
||||
const hasEnoughParks = this.tripParks.length >= 2;
|
||||
|
||||
optimizeBtn.disabled = !hasEnoughParks;
|
||||
calculateBtn.disabled = !hasEnoughParks;
|
||||
}
|
||||
|
||||
clearTrip() {
|
||||
this.tripParks = [];
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
|
||||
document.getElementById('trip-summary').classList.add('hidden');
|
||||
}
|
||||
|
||||
saveTrip() {
|
||||
if (this.tripParks.length === 0) return;
|
||||
|
||||
const tripName = prompt('Enter a name for this trip:');
|
||||
if (!tripName) return;
|
||||
|
||||
// Create temporary form for HTMX request
|
||||
const tempForm = document.createElement('form');
|
||||
tempForm.setAttribute('hx-post', '{% url "parks:htmx_save_trip" %}');
|
||||
tempForm.setAttribute('hx-vals', JSON.stringify({
|
||||
name: tripName,
|
||||
park_ids: this.tripParks.map(p => p.id)
|
||||
}));
|
||||
tempForm.setAttribute('hx-trigger', 'submit');
|
||||
tempForm.setAttribute('hx-swap', 'none');
|
||||
|
||||
// Add CSRF token
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrfmiddlewaretoken';
|
||||
csrfInput.value = '{{ csrf_token }}';
|
||||
tempForm.appendChild(csrfInput);
|
||||
|
||||
tempForm.addEventListener('htmx:afterRequest', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.detail.xhr.responseText);
|
||||
|
||||
if (data.status === 'success') {
|
||||
alert('Trip saved successfully!');
|
||||
// Refresh saved trips using HTMX
|
||||
htmx.trigger(document.getElementById('saved-trips'), 'refresh');
|
||||
} else {
|
||||
alert('Failed to save trip: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save trip failed:', error);
|
||||
alert('Failed to save trip');
|
||||
}
|
||||
document.body.removeChild(tempForm);
|
||||
});
|
||||
|
||||
document.body.appendChild(tempForm);
|
||||
htmx.trigger(tempForm, 'submit');
|
||||
}
|
||||
}
|
||||
|
||||
// Global function for adding parks from search results
|
||||
window.addParkToTrip = function(parkData) {
|
||||
window.tripPlanner.addParkToTrip(parkData);
|
||||
};
|
||||
|
||||
// Initialize trip planner when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.tripPlanner = new TripPlanner();
|
||||
|
||||
// Hide search results when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#park-search') && !e.target.closest('#park-search-results')) {
|
||||
document.getElementById('park-search-results').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -203,56 +203,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile filter JavaScript -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const mobileToggle = document.getElementById('mobile-filter-toggle');
|
||||
const mobilePanel = document.getElementById('mobile-filter-panel');
|
||||
const mobileOverlay = document.getElementById('mobile-filter-overlay');
|
||||
const mobileClose = document.getElementById('mobile-filter-close');
|
||||
|
||||
function openMobileFilter() {
|
||||
mobilePanel.classList.add('open');
|
||||
mobileOverlay.classList.remove('hidden');
|
||||
<!-- AlpineJS Mobile Filter Component (HTMX + AlpineJS Only) -->
|
||||
<div x-data="{
|
||||
mobileFilterOpen: false,
|
||||
openMobileFilter() {
|
||||
this.mobileFilterOpen = true;
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeMobileFilter() {
|
||||
mobilePanel.classList.remove('open');
|
||||
mobileOverlay.classList.add('hidden');
|
||||
},
|
||||
closeMobileFilter() {
|
||||
this.mobileFilterOpen = false;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
if (mobileToggle) {
|
||||
mobileToggle.addEventListener('click', openMobileFilter);
|
||||
}
|
||||
|
||||
if (mobileClose) {
|
||||
mobileClose.addEventListener('click', closeMobileFilter);
|
||||
}
|
||||
|
||||
if (mobileOverlay) {
|
||||
mobileOverlay.addEventListener('click', closeMobileFilter);
|
||||
}
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && mobilePanel.classList.contains('open')) {
|
||||
closeMobileFilter();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Dark mode toggle (if not already implemented globally)
|
||||
function toggleDarkMode() {
|
||||
document.documentElement.classList.toggle('dark');
|
||||
localStorage.setItem('darkMode', document.documentElement.classList.contains('dark'));
|
||||
}
|
||||
|
||||
// Initialize dark mode from localStorage
|
||||
if (localStorage.getItem('darkMode') === 'true' ||
|
||||
(!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
</script>
|
||||
}"
|
||||
@keydown.escape="closeMobileFilter()"
|
||||
style="display: none;">
|
||||
<!-- Mobile filter functionality handled by AlpineJS -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -298,135 +298,23 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced JavaScript for Advanced Search -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Search type toggle functionality
|
||||
const searchTypeRadios = document.querySelectorAll('input[name="search_type"]');
|
||||
const parkFilters = document.getElementById('park-filters');
|
||||
const rideFilters = document.getElementById('ride-filters');
|
||||
|
||||
searchTypeRadios.forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
if (this.value === 'parks') {
|
||||
parkFilters.classList.remove('hidden');
|
||||
rideFilters.classList.add('hidden');
|
||||
} else {
|
||||
parkFilters.classList.add('hidden');
|
||||
rideFilters.classList.remove('hidden');
|
||||
<!-- AlpineJS Advanced Search Component (HTMX + AlpineJS Only) -->
|
||||
<div x-data="{
|
||||
searchType: 'parks',
|
||||
viewMode: 'grid',
|
||||
toggleSearchType(type) {
|
||||
this.searchType = type;
|
||||
},
|
||||
clearFilters() {
|
||||
document.getElementById('advanced-search-form').reset();
|
||||
this.searchType = 'parks';
|
||||
},
|
||||
setViewMode(mode) {
|
||||
this.viewMode = mode;
|
||||
}
|
||||
|
||||
// Update radio button visual state
|
||||
searchTypeRadios.forEach(r => {
|
||||
const indicator = r.parentElement.querySelector('div div');
|
||||
if (r.checked) {
|
||||
indicator.classList.remove('opacity-0');
|
||||
indicator.classList.add('opacity-100');
|
||||
} else {
|
||||
indicator.classList.remove('opacity-100');
|
||||
indicator.classList.add('opacity-0');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Range slider updates
|
||||
const rangeInputs = document.querySelectorAll('input[type="range"]');
|
||||
rangeInputs.forEach(input => {
|
||||
const updateValue = () => {
|
||||
const valueSpan = document.getElementById(input.name + '-value');
|
||||
if (valueSpan) {
|
||||
let value = input.value;
|
||||
if (input.name.includes('height')) value += 'ft';
|
||||
if (input.name.includes('speed')) value += 'mph';
|
||||
valueSpan.textContent = value;
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('input', updateValue);
|
||||
updateValue(); // Initial update
|
||||
});
|
||||
|
||||
// Checkbox styling
|
||||
const checkboxes = document.querySelectorAll('input[type="checkbox"]');
|
||||
checkboxes.forEach(checkbox => {
|
||||
const customCheckbox = checkbox.parentElement.querySelector('.checkbox-custom');
|
||||
if (customCheckbox) {
|
||||
checkbox.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
customCheckbox.classList.add('checked');
|
||||
} else {
|
||||
customCheckbox.classList.remove('checked');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Clear filters functionality
|
||||
document.getElementById('clear-filters').addEventListener('click', function() {
|
||||
const form = document.getElementById('advanced-search-form');
|
||||
form.reset();
|
||||
|
||||
// Reset visual states
|
||||
searchTypeRadios[0].checked = true;
|
||||
searchTypeRadios[0].dispatchEvent(new Event('change'));
|
||||
|
||||
rangeInputs.forEach(input => {
|
||||
input.value = input.min;
|
||||
input.dispatchEvent(new Event('input'));
|
||||
});
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = false;
|
||||
const customCheckbox = checkbox.parentElement.querySelector('.checkbox-custom');
|
||||
if (customCheckbox) {
|
||||
customCheckbox.classList.remove('checked');
|
||||
}
|
||||
});
|
||||
|
||||
// Clear results
|
||||
document.getElementById('search-results').innerHTML = `
|
||||
<div class="text-center py-16">
|
||||
<div class="w-24 h-24 bg-gradient-to-r from-thrill-primary to-purple-500 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<i class="fas fa-search text-3xl text-white"></i>
|
||||
}" style="display: none;">
|
||||
<!-- Advanced search functionality handled by AlpineJS + HTMX -->
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold mb-4">Ready to Explore?</h3>
|
||||
<p class="text-neutral-600 dark:text-neutral-400 max-w-md mx-auto">
|
||||
Use the filters on the left to discover amazing theme parks and thrilling rides that match your preferences.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
// View toggle functionality
|
||||
const gridViewBtn = document.getElementById('grid-view');
|
||||
const listViewBtn = document.getElementById('list-view');
|
||||
|
||||
gridViewBtn.addEventListener('click', function() {
|
||||
this.classList.add('bg-thrill-primary', 'text-white');
|
||||
this.classList.remove('text-neutral-600', 'dark:text-neutral-400');
|
||||
listViewBtn.classList.remove('bg-thrill-primary', 'text-white');
|
||||
listViewBtn.classList.add('text-neutral-600', 'dark:text-neutral-400');
|
||||
|
||||
// Update results view
|
||||
const resultsContainer = document.getElementById('search-results');
|
||||
resultsContainer.classList.remove('list-view');
|
||||
resultsContainer.classList.add('grid-view');
|
||||
});
|
||||
|
||||
listViewBtn.addEventListener('click', function() {
|
||||
this.classList.add('bg-thrill-primary', 'text-white');
|
||||
this.classList.remove('text-neutral-600', 'dark:text-neutral-400');
|
||||
gridViewBtn.classList.remove('bg-thrill-primary', 'text-white');
|
||||
gridViewBtn.classList.add('text-neutral-600', 'dark:text-neutral-400');
|
||||
|
||||
// Update results view
|
||||
const resultsContainer = document.getElementById('search-results');
|
||||
resultsContainer.classList.remove('grid-view');
|
||||
resultsContainer.classList.add('list-view');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Custom CSS for checkboxes and enhanced styling -->
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user