mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 05:31:09 -05:00
Refactor ride filters and forms to use AlpineJS for state management and HTMX for AJAX interactions
- Enhanced filter sidebar with AlpineJS for collapsible sections and localStorage persistence. - Removed custom JavaScript in favor of AlpineJS for managing filter states and interactions. - Updated ride form to utilize AlpineJS for handling manufacturer, designer, and ride model selections. - Simplified search script to leverage AlpineJS for managing search input and suggestions. - Improved error handling for HTMX requests with minimal JavaScript. - Refactored ride form data handling to encapsulate logic within an AlpineJS component.
This commit is contained in:
@@ -51,4 +51,6 @@ tailwindcss, django, django-cotton, htmx, alpinejs, django-rest-framework, postg
|
|||||||
- RichChoiceField over Django choices
|
- RichChoiceField over Django choices
|
||||||
- Progressive enhancement required
|
- Progressive enhancement required
|
||||||
|
|
||||||
|
- We prefer to edit existing files instead of creating new ones.
|
||||||
|
|
||||||
YOU ARE STRICTLY AND ABSOLUTELY FORBIDDEN FROM IGNORING, BYPASSING, OR AVOIDING THESE RULES IN ANY WAY WITH NO EXCEPTIONS!!!
|
YOU ARE STRICTLY AND ABSOLUTELY FORBIDDEN FROM IGNORING, BYPASSING, OR AVOIDING THESE RULES IN ANY WAY WITH NO EXCEPTIONS!!!
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
{% if location.id %}data-location-id="{{ location.id }}"{% endif %}
|
{% if location.id %}data-location-id="{{ location.id }}"{% endif %}
|
||||||
{% if location.type %}data-location-type="{{ location.type }}"{% endif %}
|
{% if location.type %}data-location-type="{{ location.type }}"{% endif %}
|
||||||
{% if location.latitude and location.longitude %}data-lat="{{ location.latitude }}" data-lng="{{ location.longitude }}"{% endif %}
|
{% if location.latitude and location.longitude %}data-lat="{{ location.latitude }}" data-lng="{{ location.longitude }}"{% endif %}
|
||||||
{% if clickable %}onclick="{{ onclick_action|default:'window.location.href=\''|add:location.get_absolute_url|add:'\'' }}"{% endif %}>
|
x-data="locationCard()"
|
||||||
|
{% if clickable %}@click="handleCardClick('{{ location.get_absolute_url }}')"{% endif %}>
|
||||||
|
|
||||||
<!-- Card Header -->
|
<!-- Card Header -->
|
||||||
<div class="flex items-start justify-between mb-3">
|
<div class="flex items-start justify-between mb-3">
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if show_map_action %}
|
{% if show_map_action %}
|
||||||
<button onclick="showOnMap('{{ location.type }}', {{ location.id }})"
|
<button @click="showOnMap('{{ location.type }}', {{ location.id }})"
|
||||||
class="px-3 py-2 text-sm text-green-600 border border-green-600 rounded-lg hover:bg-green-50 dark:hover:bg-green-900 transition-colors"
|
class="px-3 py-2 text-sm text-green-600 border border-green-600 rounded-lg hover:bg-green-50 dark:hover:bg-green-900 transition-colors"
|
||||||
title="Show on map">
|
title="Show on map">
|
||||||
<i class="fas fa-map-marker-alt"></i>
|
<i class="fas fa-map-marker-alt"></i>
|
||||||
@@ -77,7 +78,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if show_trip_action %}
|
{% if show_trip_action %}
|
||||||
<button onclick="addToTrip({{ location|safe }})"
|
<button @click="addToTrip({{ location|safe }})"
|
||||||
class="px-3 py-2 text-sm text-purple-600 border border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900 transition-colors"
|
class="px-3 py-2 text-sm text-purple-600 border border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900 transition-colors"
|
||||||
title="Add to trip">
|
title="Add to trip">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
@@ -297,50 +298,55 @@ This would be in templates/maps/partials/park_card_content.html
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- Location Card JavaScript -->
|
|
||||||
<script>
|
<script>
|
||||||
// Global functions for location card actions
|
document.addEventListener('alpine:init', () => {
|
||||||
window.showOnMap = function(type, id) {
|
Alpine.data('locationCard', () => ({
|
||||||
// Emit custom event for map integration
|
selected: false,
|
||||||
const event = new CustomEvent('showLocationOnMap', {
|
|
||||||
detail: { type, id }
|
|
||||||
});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addToTrip = function(locationData) {
|
init() {
|
||||||
// Emit custom event for trip integration
|
// Listen for card selection events
|
||||||
const event = new CustomEvent('addLocationToTrip', {
|
this.$el.addEventListener('click', (e) => {
|
||||||
detail: locationData
|
if (this.$el.dataset.locationId) {
|
||||||
|
this.handleCardSelection();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
document.dispatchEvent(event);
|
},
|
||||||
};
|
|
||||||
|
|
||||||
// Handle location card selection
|
handleCardClick(url) {
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
if (url) {
|
||||||
document.addEventListener('click', function(e) {
|
window.location.href = url;
|
||||||
const card = e.target.closest('.location-card');
|
}
|
||||||
if (card && card.dataset.locationId) {
|
},
|
||||||
// Remove previous selections
|
|
||||||
|
showOnMap(type, id) {
|
||||||
|
// Emit custom event for map integration using AlpineJS approach
|
||||||
|
this.$dispatch('showLocationOnMap', { type, id });
|
||||||
|
},
|
||||||
|
|
||||||
|
addToTrip(locationData) {
|
||||||
|
// Emit custom event for trip integration using AlpineJS approach
|
||||||
|
this.$dispatch('addLocationToTrip', locationData);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleCardSelection() {
|
||||||
|
// Remove previous selections using AlpineJS approach
|
||||||
document.querySelectorAll('.location-card.selected').forEach(c => {
|
document.querySelectorAll('.location-card.selected').forEach(c => {
|
||||||
c.classList.remove('selected');
|
c.classList.remove('selected');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add selection to clicked card
|
// Add selection to this card
|
||||||
card.classList.add('selected');
|
this.$el.classList.add('selected');
|
||||||
|
this.selected = true;
|
||||||
|
|
||||||
// Emit selection event
|
// Emit selection event using AlpineJS $dispatch
|
||||||
const event = new CustomEvent('locationCardSelected', {
|
this.$dispatch('locationCardSelected', {
|
||||||
detail: {
|
id: this.$el.dataset.locationId,
|
||||||
id: card.dataset.locationId,
|
type: this.$el.dataset.locationType,
|
||||||
type: card.dataset.locationType,
|
lat: this.$el.dataset.lat,
|
||||||
lat: card.dataset.lat,
|
lng: this.$el.dataset.lng,
|
||||||
lng: card.dataset.lng,
|
element: this.$el
|
||||||
element: card
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
document.dispatchEvent(event);
|
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="location-widget" id="locationWidget">
|
<div class="location-widget" id="locationWidget" x-data="locationWidget()">
|
||||||
{# Search Form #}
|
{# Search Form #}
|
||||||
<div class="relative mb-4">
|
<div class="relative mb-4">
|
||||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
@@ -31,9 +31,18 @@
|
|||||||
placeholder="Search for a location..."
|
placeholder="Search for a location..."
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
style="z-index: 10;">
|
style="z-index: 10;">
|
||||||
<div id="searchResults"
|
<div x-show="showResults && searchResults.length > 0"
|
||||||
|
x-transition
|
||||||
style="position: absolute; top: 100%; left: 0; right: 0; z-index: 1000;"
|
style="position: absolute; top: 100%; left: 0; right: 0; z-index: 1000;"
|
||||||
class="hidden w-full mt-1 overflow-auto bg-white border rounded-md shadow-lg max-h-60 dark:bg-gray-700 dark:border-gray-600">
|
class="w-full mt-1 overflow-auto bg-white border rounded-md shadow-lg max-h-60 dark:bg-gray-700 dark:border-gray-600">
|
||||||
|
<template x-for="(result, index) in searchResults" :key="index">
|
||||||
|
<div class="p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||||
|
@click="selectLocation(result)">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white" x-text="result.display_name || result.name || ''"></div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
x-text="[result.street, result.city || (result.address && (result.address.city || result.address.town || result.address.village)), result.state || (result.address && (result.address.state || result.address.region)), result.country || (result.address && result.address.country), result.postal_code || (result.address && result.address.postcode)].filter(Boolean).join(', ')"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,40 +113,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('alpine:init', () => {
|
||||||
let map = null;
|
Alpine.data('locationWidget', () => ({
|
||||||
let marker = null;
|
map: null,
|
||||||
const searchInput = document.getElementById('locationSearch');
|
marker: null,
|
||||||
const searchResults = document.getElementById('searchResults');
|
searchTimeout: null,
|
||||||
let searchTimeout;
|
searchResults: [],
|
||||||
|
showResults: false,
|
||||||
|
|
||||||
function normalizeCoordinate(value, maxDigits, decimalPlaces) {
|
init() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.initMap();
|
||||||
|
this.setupEventListeners();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
normalizeCoordinate(value, maxDigits, decimalPlaces) {
|
||||||
try {
|
try {
|
||||||
// Convert to string-3 with exact decimal places
|
|
||||||
const rounded = Number(value).toFixed(decimalPlaces);
|
const rounded = Number(value).toFixed(decimalPlaces);
|
||||||
|
|
||||||
// Convert to string-3 without decimal point for digit counting
|
|
||||||
const strValue = rounded.replace('.', '').replace('-', '');
|
const strValue = rounded.replace('.', '').replace('-', '');
|
||||||
// Remove trailing zeros
|
|
||||||
const strippedValue = strValue.replace(/0+$/, '');
|
const strippedValue = strValue.replace(/0+$/, '');
|
||||||
|
|
||||||
// If total digits exceed maxDigits, round further
|
|
||||||
if (strippedValue.length > maxDigits) {
|
if (strippedValue.length > maxDigits) {
|
||||||
return Number(Number(value).toFixed(decimalPlaces - 1));
|
return Number(Number(value).toFixed(decimalPlaces - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the string-3 representation to preserve exact decimal places
|
|
||||||
return rounded;
|
return rounded;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Coordinate normalization failed:', error);
|
console.error('Coordinate normalization failed:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
function validateCoordinates(lat, lng) {
|
validateCoordinates(lat, lng) {
|
||||||
// Normalize coordinates
|
const normalizedLat = this.normalizeCoordinate(lat, 9, 6);
|
||||||
const normalizedLat = normalizeCoordinate(lat, 9, 6);
|
const normalizedLng = this.normalizeCoordinate(lng, 10, 6);
|
||||||
const normalizedLng = normalizeCoordinate(lng, 10, 6);
|
|
||||||
|
|
||||||
if (normalizedLat === null || normalizedLng === null) {
|
if (normalizedLat === null || normalizedLng === null) {
|
||||||
throw new Error('Invalid coordinate format');
|
throw new Error('Invalid coordinate format');
|
||||||
@@ -154,53 +164,110 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { lat: normalizedLat, lng: normalizedLng };
|
return { lat: normalizedLat, lng: normalizedLng };
|
||||||
}
|
},
|
||||||
|
|
||||||
// Initialize map
|
initMap() {
|
||||||
function initMap() {
|
this.map = L.map('locationMap').setView([0, 0], 2);
|
||||||
map = L.map('locationMap').setView([0, 0], 2);
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '© OpenStreetMap contributors'
|
attribution: '© OpenStreetMap contributors'
|
||||||
}).addTo(map);
|
}).addTo(this.map);
|
||||||
|
|
||||||
// Initialize with existing coordinates if available
|
// Initialize with existing coordinates if available
|
||||||
const initialLat = document.getElementById('latitude').value;
|
const initialLat = this.$el.querySelector('#latitude').value;
|
||||||
const initialLng = document.getElementById('longitude').value;
|
const initialLng = this.$el.querySelector('#longitude').value;
|
||||||
if (initialLat && initialLng) {
|
if (initialLat && initialLng) {
|
||||||
try {
|
try {
|
||||||
const normalized = validateCoordinates(initialLat, initialLng);
|
const normalized = this.validateCoordinates(initialLat, initialLng);
|
||||||
addMarker(normalized.lat, normalized.lng);
|
this.addMarker(normalized.lat, normalized.lng);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Invalid initial coordinates:', error);
|
console.error('Invalid initial coordinates:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle map clicks - HTMX version
|
// Handle map clicks using AlpineJS approach
|
||||||
map.on('click', function(e) {
|
this.map.on('click', (e) => {
|
||||||
try {
|
this.handleMapClick(e.latlng.lat, e.latlng.lng);
|
||||||
const normalized = validateCoordinates(e.latlng.lat, e.latlng.lng);
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Create a temporary form for HTMX request
|
setupEventListeners() {
|
||||||
|
const searchInput = this.$el.querySelector('#locationSearch');
|
||||||
|
const form = this.$el.closest('form');
|
||||||
|
|
||||||
|
// Search input handler
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
this.handleSearchInput(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form submit handler
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
this.handleFormSubmit(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click outside handler
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!this.$el.contains(e.target)) {
|
||||||
|
this.showResults = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
handleMapClick(lat, lng) {
|
||||||
|
try {
|
||||||
|
const normalized = this.validateCoordinates(lat, lng);
|
||||||
|
this.reverseGeocode(normalized.lat, normalized.lng);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Location update failed:', error);
|
||||||
|
alert(error.message || 'Failed to update location. Please try again.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSearchInput(query) {
|
||||||
|
clearTimeout(this.searchTimeout);
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
this.showResults = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchTimeout = setTimeout(() => {
|
||||||
|
this.searchLocation(query.trim());
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleFormSubmit(e) {
|
||||||
|
const lat = this.$el.querySelector('#latitude').value;
|
||||||
|
const lng = this.$el.querySelector('#longitude').value;
|
||||||
|
|
||||||
|
if (lat && lng) {
|
||||||
|
try {
|
||||||
|
this.validateCoordinates(lat, lng);
|
||||||
|
} catch (error) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert(error.message || 'Invalid coordinates. Please check the location.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reverseGeocode(lat, lng) {
|
||||||
const tempForm = document.createElement('form');
|
const tempForm = document.createElement('form');
|
||||||
tempForm.style.display = 'none';
|
tempForm.style.display = 'none';
|
||||||
tempForm.setAttribute('hx-get', '/parks/search/reverse-geocode/');
|
tempForm.setAttribute('hx-get', '/parks/search/reverse-geocode/');
|
||||||
tempForm.setAttribute('hx-vals', JSON.stringify({
|
tempForm.setAttribute('hx-vals', JSON.stringify({ lat, lon: lng }));
|
||||||
lat: normalized.lat,
|
|
||||||
lon: normalized.lng
|
|
||||||
}));
|
|
||||||
tempForm.setAttribute('hx-trigger', 'submit');
|
tempForm.setAttribute('hx-trigger', 'submit');
|
||||||
tempForm.setAttribute('hx-swap', 'none');
|
tempForm.setAttribute('hx-swap', 'none');
|
||||||
|
|
||||||
// Add event listener for HTMX response
|
tempForm.addEventListener('htmx:afterRequest', (event) => {
|
||||||
tempForm.addEventListener('htmx:afterRequest', function(event) {
|
|
||||||
if (event.detail.successful) {
|
if (event.detail.successful) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.detail.xhr.responseText);
|
const data = JSON.parse(event.detail.xhr.responseText);
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
throw new Error(data.error);
|
throw new Error(data.error);
|
||||||
}
|
}
|
||||||
updateLocation(normalized.lat, normalized.lng, data);
|
this.updateLocation(lat, lng, data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Location update failed:', error);
|
console.error('Location update failed:', error);
|
||||||
alert(error.message || 'Failed to update location. Please try again.');
|
alert(error.message || 'Failed to update location. Please try again.');
|
||||||
@@ -209,147 +276,45 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
console.error('Geocoding request failed');
|
console.error('Geocoding request failed');
|
||||||
alert('Failed to update location. Please try again.');
|
alert('Failed to update location. Please try again.');
|
||||||
}
|
}
|
||||||
// Clean up temporary form
|
|
||||||
document.body.removeChild(tempForm);
|
document.body.removeChild(tempForm);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.appendChild(tempForm);
|
document.body.appendChild(tempForm);
|
||||||
htmx.trigger(tempForm, 'submit');
|
htmx.trigger(tempForm, 'submit');
|
||||||
|
},
|
||||||
|
|
||||||
} catch (error) {
|
searchLocation(query) {
|
||||||
console.error('Location update failed:', error);
|
|
||||||
alert(error.message || 'Failed to update location. Please try again.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize map
|
|
||||||
initMap();
|
|
||||||
|
|
||||||
// Handle location search - HTMX version
|
|
||||||
searchInput.addEventListener('input', function() {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
const query = this.value.trim();
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
searchResults.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
searchTimeout = setTimeout(function() {
|
|
||||||
// Create a temporary form for HTMX request
|
|
||||||
const tempForm = document.createElement('form');
|
const tempForm = document.createElement('form');
|
||||||
tempForm.style.display = 'none';
|
tempForm.style.display = 'none';
|
||||||
tempForm.setAttribute('hx-get', '/parks/search/location/');
|
tempForm.setAttribute('hx-get', '/parks/search/location/');
|
||||||
tempForm.setAttribute('hx-vals', JSON.stringify({
|
tempForm.setAttribute('hx-vals', JSON.stringify({ q: query }));
|
||||||
q: query
|
|
||||||
}));
|
|
||||||
tempForm.setAttribute('hx-trigger', 'submit');
|
tempForm.setAttribute('hx-trigger', 'submit');
|
||||||
tempForm.setAttribute('hx-swap', 'none');
|
tempForm.setAttribute('hx-swap', 'none');
|
||||||
|
|
||||||
// Add event listener for HTMX response
|
tempForm.addEventListener('htmx:afterRequest', (event) => {
|
||||||
tempForm.addEventListener('htmx:afterRequest', function(event) {
|
|
||||||
if (event.detail.successful) {
|
if (event.detail.successful) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.detail.xhr.responseText);
|
const data = JSON.parse(event.detail.xhr.responseText);
|
||||||
|
this.searchResults = data.results || [];
|
||||||
if (data.results && data.results.length > 0) {
|
this.showResults = true;
|
||||||
const resultsHtml = data.results.map((result, index) => `
|
|
||||||
<div class="p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
|
|
||||||
data-result-index="${index}">
|
|
||||||
<div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div>
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
${[
|
|
||||||
result.street,
|
|
||||||
result.city || (result.address && (result.address.city || result.address.town || result.address.village)),
|
|
||||||
result.state || (result.address && (result.address.state || result.address.region)),
|
|
||||||
result.country || (result.address && result.address.country),
|
|
||||||
result.postal_code || (result.address && result.address.postcode)
|
|
||||||
].filter(Boolean).join(', ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
searchResults.innerHTML = resultsHtml;
|
|
||||||
searchResults.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Store results data
|
|
||||||
searchResults.dataset.results = JSON.stringify(data.results);
|
|
||||||
|
|
||||||
// Add click handlers
|
|
||||||
searchResults.querySelectorAll('[data-result-index]').forEach(el => {
|
|
||||||
el.addEventListener('click', function() {
|
|
||||||
const results = JSON.parse(searchResults.dataset.results);
|
|
||||||
const result = results[this.dataset.resultIndex];
|
|
||||||
selectLocation(result);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
searchResults.innerHTML = '<div class="p-2 text-gray-500 dark:text-gray-400">No results found</div>';
|
|
||||||
searchResults.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search failed:', error);
|
console.error('Search failed:', error);
|
||||||
searchResults.innerHTML = '<div class="p-2 text-red-500 dark:text-red-400">Search failed. Please try again.</div>';
|
this.searchResults = [];
|
||||||
searchResults.classList.remove('hidden');
|
this.showResults = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Search request failed');
|
console.error('Search request failed');
|
||||||
searchResults.innerHTML = '<div class="p-2 text-red-500 dark:text-red-400">Search failed. Please try again.</div>';
|
this.searchResults = [];
|
||||||
searchResults.classList.remove('hidden');
|
this.showResults = false;
|
||||||
}
|
}
|
||||||
// Clean up temporary form
|
|
||||||
document.body.removeChild(tempForm);
|
document.body.removeChild(tempForm);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.appendChild(tempForm);
|
document.body.appendChild(tempForm);
|
||||||
htmx.trigger(tempForm, 'submit');
|
htmx.trigger(tempForm, 'submit');
|
||||||
}, 300);
|
},
|
||||||
});
|
|
||||||
|
|
||||||
// Hide search results when clicking outside
|
selectLocation(result) {
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
if (!searchResults.contains(e.target) && e.target !== searchInput) {
|
|
||||||
searchResults.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function addMarker(lat, lng) {
|
|
||||||
if (marker) {
|
|
||||||
marker.remove();
|
|
||||||
}
|
|
||||||
marker = L.marker([lat, lng]).addTo(map);
|
|
||||||
map.setView([lat, lng], 13);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLocation(lat, lng, data) {
|
|
||||||
try {
|
|
||||||
const normalized = validateCoordinates(lat, lng);
|
|
||||||
|
|
||||||
// Update coordinates
|
|
||||||
document.getElementById('latitude').value = normalized.lat;
|
|
||||||
document.getElementById('longitude').value = normalized.lng;
|
|
||||||
|
|
||||||
// Update marker
|
|
||||||
addMarker(normalized.lat, normalized.lng);
|
|
||||||
|
|
||||||
// Update form fields with English names where available
|
|
||||||
const address = data.address || {};
|
|
||||||
document.getElementById('streetAddress').value =
|
|
||||||
`${address.house_number || ''} ${address.road || address.street || ''}`.trim() || '';
|
|
||||||
document.getElementById('city').value =
|
|
||||||
address.city || address.town || address.village || '';
|
|
||||||
document.getElementById('state').value =
|
|
||||||
address.state || address.region || '';
|
|
||||||
document.getElementById('country').value = address.country || '';
|
|
||||||
document.getElementById('postalCode').value = address.postcode || '';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Location update failed:', error);
|
|
||||||
alert(error.message || 'Failed to update location. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectLocation(result) {
|
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -360,9 +325,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
throw new Error('Invalid coordinates in search result');
|
throw new Error('Invalid coordinates in search result');
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = validateCoordinates(lat, lon);
|
const normalized = this.validateCoordinates(lat, lon);
|
||||||
|
|
||||||
// Create a normalized address object
|
|
||||||
const address = {
|
const address = {
|
||||||
name: result.display_name || result.name || '',
|
name: result.display_name || result.name || '',
|
||||||
address: {
|
address: {
|
||||||
@@ -375,29 +339,49 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
updateLocation(normalized.lat, normalized.lng, address);
|
this.updateLocation(normalized.lat, normalized.lng, address);
|
||||||
searchResults.classList.add('hidden');
|
this.showResults = false;
|
||||||
searchInput.value = address.name;
|
this.$el.querySelector('#locationSearch').value = address.name;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Location selection failed:', error);
|
console.error('Location selection failed:', error);
|
||||||
alert(error.message || 'Failed to select location. Please try again.');
|
alert(error.message || 'Failed to select location. Please try again.');
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addMarker(lat, lng) {
|
||||||
|
if (this.marker) {
|
||||||
|
this.marker.remove();
|
||||||
}
|
}
|
||||||
|
this.marker = L.marker([lat, lng]).addTo(this.map);
|
||||||
|
this.map.setView([lat, lng], 13);
|
||||||
|
},
|
||||||
|
|
||||||
// Add form submit handler
|
updateLocation(lat, lng, data) {
|
||||||
const form = document.querySelector('form');
|
|
||||||
form.addEventListener('submit', function(e) {
|
|
||||||
const lat = document.getElementById('latitude').value;
|
|
||||||
const lng = document.getElementById('longitude').value;
|
|
||||||
|
|
||||||
if (lat && lng) {
|
|
||||||
try {
|
try {
|
||||||
validateCoordinates(lat, lng);
|
const normalized = this.validateCoordinates(lat, lng);
|
||||||
|
|
||||||
|
// Update coordinates using AlpineJS $el
|
||||||
|
this.$el.querySelector('#latitude').value = normalized.lat;
|
||||||
|
this.$el.querySelector('#longitude').value = normalized.lng;
|
||||||
|
|
||||||
|
// Update marker
|
||||||
|
this.addMarker(normalized.lat, normalized.lng);
|
||||||
|
|
||||||
|
// Update form fields
|
||||||
|
const address = data.address || {};
|
||||||
|
this.$el.querySelector('#streetAddress').value =
|
||||||
|
`${address.house_number || ''} ${address.road || address.street || ''}`.trim() || '';
|
||||||
|
this.$el.querySelector('#city').value =
|
||||||
|
address.city || address.town || address.village || '';
|
||||||
|
this.$el.querySelector('#state').value =
|
||||||
|
address.state || address.region || '';
|
||||||
|
this.$el.querySelector('#country').value = address.country || '';
|
||||||
|
this.$el.querySelector('#postalCode').value = address.postcode || '';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
e.preventDefault();
|
console.error('Location update failed:', error);
|
||||||
alert(error.message || 'Invalid coordinates. Please check the location.');
|
alert(error.message || 'Failed to update location. Please try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -124,7 +124,81 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container px-4 mx-auto">
|
<!-- HTMX + AlpineJS ONLY - NO CUSTOM JAVASCRIPT -->
|
||||||
|
<div x-data="{
|
||||||
|
tripParks: [],
|
||||||
|
showAllParks: false,
|
||||||
|
mapInitialized: false,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Initialize map via HTMX
|
||||||
|
this.initializeMap();
|
||||||
|
},
|
||||||
|
|
||||||
|
initializeMap() {
|
||||||
|
// Use HTMX to load map component
|
||||||
|
htmx.ajax('GET', '/maps/roadtrip-map/', {
|
||||||
|
target: '#map-container',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
this.mapInitialized = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
addParkToTrip(parkId, parkName, parkLocation) {
|
||||||
|
// Check if park already exists
|
||||||
|
if (!this.tripParks.find(p => p.id === parkId)) {
|
||||||
|
this.tripParks.push({
|
||||||
|
id: parkId,
|
||||||
|
name: parkName,
|
||||||
|
location: parkLocation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeParkFromTrip(parkId) {
|
||||||
|
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearTrip() {
|
||||||
|
this.tripParks = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
optimizeRoute() {
|
||||||
|
if (this.tripParks.length >= 2) {
|
||||||
|
// Use HTMX to optimize route
|
||||||
|
htmx.ajax('POST', '/trips/optimize/', {
|
||||||
|
values: { parks: this.tripParks.map(p => p.id) },
|
||||||
|
target: '#trip-summary',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
calculateRoute() {
|
||||||
|
if (this.tripParks.length >= 2) {
|
||||||
|
// Use HTMX to calculate route
|
||||||
|
htmx.ajax('POST', '/trips/calculate/', {
|
||||||
|
values: { parks: this.tripParks.map(p => p.id) },
|
||||||
|
target: '#trip-summary',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
saveTrip() {
|
||||||
|
if (this.tripParks.length > 0) {
|
||||||
|
// Use HTMX to save trip
|
||||||
|
htmx.ajax('POST', '/trips/save/', {
|
||||||
|
values: {
|
||||||
|
name: 'Trip ' + new Date().toLocaleDateString(),
|
||||||
|
parks: this.tripParks.map(p => p.id)
|
||||||
|
},
|
||||||
|
target: '#saved-trips',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}" class="container px-4 mx-auto">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
|
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -167,7 +241,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 hidden">
|
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 hidden">
|
||||||
<!-- Search results will be populated here -->
|
<!-- Search results will be populated here via HTMX -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -175,61 +249,80 @@
|
|||||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
|
||||||
<button id="clear-trip"
|
<button class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||||||
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
|
||||||
@click="clearTrip()">
|
@click="clearTrip()">
|
||||||
<i class="mr-1 fas fa-trash"></i>Clear All
|
<i class="mr-1 fas fa-trash"></i>Clear All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="trip-parks" class="space-y-2 min-h-20">
|
<div id="trip-parks" class="space-y-2 min-h-20">
|
||||||
<div id="empty-trip" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
<template x-if="tripParks.length === 0">
|
||||||
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
<i class="fas fa-route text-3xl mb-3"></i>
|
<i class="fas fa-route text-3xl mb-3"></i>
|
||||||
<p>Add parks to start planning your trip</p>
|
<p>Add parks to start planning your trip</p>
|
||||||
<p class="text-sm mt-1">Search above or click parks on the map</p>
|
<p class="text-sm mt-1">Search above or click parks on the map</p>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-for="(park, index) in tripParks" :key="park.id">
|
||||||
|
<div class="park-card">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-bold mr-3"
|
||||||
|
x-text="index + 1"></div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-gray-900 dark:text-white" x-text="park.name"></h4>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400" x-text="park.location"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="removeParkFromTrip(park.id)"
|
||||||
|
class="text-red-500 hover:text-red-700">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 space-y-2">
|
<div class="mt-4 space-y-2">
|
||||||
<button id="optimize-route"
|
<button class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
@click="optimizeRoute()"
|
||||||
@click="optimizeRoute()" :disabled="tripParks.length < 2">
|
:disabled="tripParks.length < 2">
|
||||||
<i class="mr-2 fas fa-route"></i>Optimize Route
|
<i class="mr-2 fas fa-route"></i>Optimize Route
|
||||||
</button>
|
</button>
|
||||||
<button id="calculate-route"
|
<button class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
@click="calculateRoute()"
|
||||||
@click="calculateRoute()" :disabled="tripParks.length < 2">
|
:disabled="tripParks.length < 2">
|
||||||
<i class="mr-2 fas fa-map"></i>Calculate Route
|
<i class="mr-2 fas fa-map"></i>Calculate Route
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Trip Summary -->
|
<!-- Trip Summary -->
|
||||||
<div id="trip-summary" class="trip-summary-card hidden">
|
<div id="trip-summary" class="trip-summary-card" x-show="tripParks.length >= 2" x-transition>
|
||||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Trip Summary</h3>
|
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Trip Summary</h3>
|
||||||
|
|
||||||
<div class="trip-stats">
|
<div class="trip-stats">
|
||||||
<div class="trip-stat">
|
<div class="trip-stat">
|
||||||
<div class="trip-stat-value" id="total-distance">-</div>
|
<div class="trip-stat-value">-</div>
|
||||||
<div class="trip-stat-label">Total Miles</div>
|
<div class="trip-stat-label">Total Miles</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="trip-stat">
|
<div class="trip-stat">
|
||||||
<div class="trip-stat-value" id="total-time">-</div>
|
<div class="trip-stat-value">-</div>
|
||||||
<div class="trip-stat-label">Drive Time</div>
|
<div class="trip-stat-label">Drive Time</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="trip-stat">
|
<div class="trip-stat">
|
||||||
<div class="trip-stat-value" id="total-parks">-</div>
|
<div class="trip-stat-value" x-text="tripParks.length">-</div>
|
||||||
<div class="trip-stat-label">Parks</div>
|
<div class="trip-stat-label">Parks</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="trip-stat">
|
<div class="trip-stat">
|
||||||
<div class="trip-stat-value" id="total-rides">-</div>
|
<div class="trip-stat-value">-</div>
|
||||||
<div class="trip-stat-label">Total Rides</div>
|
<div class="trip-stat-label">Total Rides</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<button id="save-trip"
|
<button class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
|
||||||
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
|
|
||||||
@click="saveTrip()">
|
@click="saveTrip()">
|
||||||
<i class="mr-2 fas fa-save"></i>Save Trip
|
<i class="mr-2 fas fa-save"></i>Save Trip
|
||||||
</button>
|
</button>
|
||||||
@@ -243,23 +336,28 @@
|
|||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Route Map</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Route Map</h3>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button id="fit-route"
|
<button class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
hx-post="/maps/fit-route/"
|
||||||
@click="fitRoute()">
|
hx-vals='{"parks": "{{ tripParks|join:"," }}"}'
|
||||||
|
hx-target="#map-container"
|
||||||
|
hx-swap="none">
|
||||||
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
|
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
|
||||||
</button>
|
</button>
|
||||||
<button id="toggle-parks"
|
<button class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
@click="showAllParks = !showAllParks"
|
||||||
@click="toggleAllParks()">
|
hx-post="/maps/toggle-parks/"
|
||||||
<i class="mr-1 fas fa-eye"></i><span x-text="showAllParks ? 'Hide Parks' : 'Show All Parks'">Show All Parks</span>
|
hx-vals='{"show": "{{ showAllParks }}"}'
|
||||||
|
hx-target="#map-container"
|
||||||
|
hx-swap="none">
|
||||||
|
<i class="mr-1 fas fa-eye"></i>
|
||||||
|
<span x-text="showAllParks ? 'Hide Parks' : 'Show All Parks'">Show All Parks</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="map-container" class="map-container"></div>
|
<div id="map-container" class="map-container">
|
||||||
|
<!-- Map will be loaded via HTMX -->
|
||||||
<!-- Map Loading Indicator -->
|
<div class="flex items-center justify-center h-full bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||||
<div id="map-loading" class="htmx-indicator absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="w-8 h-8 mx-auto mb-4 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
<div class="w-8 h-8 mx-auto mb-4 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
|
||||||
@@ -268,6 +366,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Saved Trips Section -->
|
<!-- Saved Trips Section -->
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
@@ -286,7 +385,7 @@
|
|||||||
hx-get="{% url 'parks:htmx_saved_trips' %}"
|
hx-get="{% url 'parks:htmx_saved_trips' %}"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
hx-indicator="#trips-loading">
|
hx-indicator="#trips-loading">
|
||||||
<!-- Saved trips will be loaded here -->
|
<!-- Saved trips will be loaded here via HTMX -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="trips-loading" class="htmx-indicator text-center py-4">
|
<div id="trips-loading" class="htmx-indicator text-center py-4">
|
||||||
@@ -299,255 +398,19 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<!-- Leaflet JS -->
|
<!-- External libraries for map functionality only -->
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<!-- Leaflet Routing Machine JS -->
|
|
||||||
<script src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.js"></script>
|
<script src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.js"></script>
|
||||||
<!-- Sortable JS for drag & drop -->
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('alpine:init', () => {
|
|
||||||
Alpine.data('tripPlanner', () => ({
|
|
||||||
map: null,
|
|
||||||
tripParks: [],
|
|
||||||
allParks: [],
|
|
||||||
showAllParks: false,
|
|
||||||
routeControl: null,
|
|
||||||
parkMarkers: {},
|
|
||||||
|
|
||||||
|
<!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
|
||||||
|
<div x-data="{
|
||||||
init() {
|
init() {
|
||||||
this.initMap();
|
// Only essential HTMX error handling as shown in Context7 docs
|
||||||
this.setupSortable();
|
this.$el.addEventListener('htmx:responseError', (evt) => {
|
||||||
},
|
if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
|
||||||
|
console.error('HTMX Error:', evt.detail.xhr.status);
|
||||||
initMap() {
|
|
||||||
if (typeof L !== 'undefined') {
|
|
||||||
this.map = L.map('map-container').setView([39.8283, -98.5795], 4);
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution: '© OpenStreetMap contributors'
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
this.loadAllParks();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setupSortable() {
|
|
||||||
if (typeof Sortable !== 'undefined') {
|
|
||||||
const tripParksEl = document.getElementById('trip-parks');
|
|
||||||
if (tripParksEl) {
|
|
||||||
Sortable.create(tripParksEl, {
|
|
||||||
animation: 150,
|
|
||||||
ghostClass: 'drag-over',
|
|
||||||
onEnd: (evt) => {
|
|
||||||
this.reorderTrip(evt.oldIndex, evt.newIndex);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}"></div>
|
||||||
},
|
|
||||||
|
|
||||||
loadAllParks() {
|
|
||||||
// Load parks via HTMX or fetch
|
|
||||||
fetch('/api/parks/')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
this.allParks = data;
|
|
||||||
this.displayAllParks();
|
|
||||||
})
|
|
||||||
.catch(error => console.error('Error loading parks:', error));
|
|
||||||
},
|
|
||||||
|
|
||||||
displayAllParks() {
|
|
||||||
this.allParks.forEach(park => {
|
|
||||||
if (park.latitude && park.longitude) {
|
|
||||||
const marker = L.marker([park.latitude, park.longitude])
|
|
||||||
.bindPopup(`
|
|
||||||
<div class="p-2">
|
|
||||||
<h3 class="font-semibold">${park.name}</h3>
|
|
||||||
<p class="text-sm text-gray-600">${park.location || ''}</p>
|
|
||||||
<button onclick="Alpine.store('tripPlanner').addParkToTrip(${park.id})"
|
|
||||||
class="mt-2 px-2 py-1 bg-blue-500 text-white text-xs rounded">
|
|
||||||
Add to Trip
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
this.parkMarkers[park.id] = marker;
|
|
||||||
if (this.showAllParks) {
|
|
||||||
marker.addTo(this.map);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
addParkToTrip(parkId) {
|
|
||||||
const park = this.allParks.find(p => p.id === parkId);
|
|
||||||
if (park && !this.tripParks.find(p => p.id === parkId)) {
|
|
||||||
this.tripParks.push(park);
|
|
||||||
this.updateTripDisplay();
|
|
||||||
this.updateButtons();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
removeParkFromTrip(parkId) {
|
|
||||||
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
|
|
||||||
this.updateTripDisplay();
|
|
||||||
this.updateButtons();
|
|
||||||
this.clearRoute();
|
|
||||||
},
|
|
||||||
|
|
||||||
clearTrip() {
|
|
||||||
this.tripParks = [];
|
|
||||||
this.updateTripDisplay();
|
|
||||||
this.updateButtons();
|
|
||||||
this.clearRoute();
|
|
||||||
},
|
|
||||||
|
|
||||||
updateTripDisplay() {
|
|
||||||
const container = document.getElementById('trip-parks');
|
|
||||||
const emptyState = document.getElementById('empty-trip');
|
|
||||||
|
|
||||||
if (this.tripParks.length === 0) {
|
|
||||||
emptyState.style.display = 'block';
|
|
||||||
container.innerHTML = emptyState.outerHTML;
|
|
||||||
} else {
|
|
||||||
container.innerHTML = this.tripParks.map((park, index) => `
|
|
||||||
<div class="park-card draggable-item" data-park-id="${park.id}">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-bold mr-3">
|
|
||||||
${index + 1}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-gray-900 dark:text-white">${park.name}</h4>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">${park.location || ''}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button @click="removeParkFromTrip(${park.id})"
|
|
||||||
class="text-red-500 hover:text-red-700">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
updateButtons() {
|
|
||||||
const hasParks = this.tripParks.length >= 2;
|
|
||||||
document.getElementById('optimize-route').disabled = !hasParks;
|
|
||||||
document.getElementById('calculate-route').disabled = !hasParks;
|
|
||||||
},
|
|
||||||
|
|
||||||
optimizeRoute() {
|
|
||||||
// Implement route optimization logic
|
|
||||||
console.log('Optimizing route for', this.tripParks.length, 'parks');
|
|
||||||
},
|
|
||||||
|
|
||||||
calculateRoute() {
|
|
||||||
if (this.tripParks.length < 2) return;
|
|
||||||
|
|
||||||
this.clearRoute();
|
|
||||||
|
|
||||||
const waypoints = this.tripParks.map(park =>
|
|
||||||
L.latLng(park.latitude, park.longitude)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (typeof L.Routing !== 'undefined') {
|
|
||||||
this.routeControl = L.Routing.control({
|
|
||||||
waypoints: waypoints,
|
|
||||||
routeWhileDragging: false,
|
|
||||||
addWaypoints: false,
|
|
||||||
createMarker: (i, waypoint, n) => {
|
|
||||||
const park = this.tripParks[i];
|
|
||||||
return L.marker(waypoint.latLng, {
|
|
||||||
icon: L.divIcon({
|
|
||||||
html: `<div class="waypoint-marker-inner">${i + 1}</div>`,
|
|
||||||
className: `waypoint-marker ${i === 0 ? 'waypoint-start' : i === n - 1 ? 'waypoint-end' : 'waypoint-stop'}`,
|
|
||||||
iconSize: [30, 30]
|
|
||||||
})
|
|
||||||
}).bindPopup(park.name);
|
|
||||||
}
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
this.routeControl.on('routesfound', (e) => {
|
|
||||||
const route = e.routes[0];
|
|
||||||
this.updateTripSummary(route);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
clearRoute() {
|
|
||||||
if (this.routeControl) {
|
|
||||||
this.map.removeControl(this.routeControl);
|
|
||||||
this.routeControl = null;
|
|
||||||
}
|
|
||||||
this.hideTripSummary();
|
|
||||||
},
|
|
||||||
|
|
||||||
updateTripSummary(route) {
|
|
||||||
const summary = route.summary;
|
|
||||||
document.getElementById('total-distance').textContent = Math.round(summary.totalDistance / 1609.34); // Convert to miles
|
|
||||||
document.getElementById('total-time').textContent = Math.round(summary.totalTime / 3600) + 'h';
|
|
||||||
document.getElementById('total-parks').textContent = this.tripParks.length;
|
|
||||||
document.getElementById('total-rides').textContent = this.tripParks.reduce((sum, park) => sum + (park.ride_count || 0), 0);
|
|
||||||
|
|
||||||
document.getElementById('trip-summary').classList.remove('hidden');
|
|
||||||
},
|
|
||||||
|
|
||||||
hideTripSummary() {
|
|
||||||
document.getElementById('trip-summary').classList.add('hidden');
|
|
||||||
},
|
|
||||||
|
|
||||||
fitRoute() {
|
|
||||||
if (this.tripParks.length > 0) {
|
|
||||||
const group = new L.featureGroup(
|
|
||||||
this.tripParks.map(park => L.marker([park.latitude, park.longitude]))
|
|
||||||
);
|
|
||||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleAllParks() {
|
|
||||||
this.showAllParks = !this.showAllParks;
|
|
||||||
|
|
||||||
Object.values(this.parkMarkers).forEach(marker => {
|
|
||||||
if (this.showAllParks) {
|
|
||||||
marker.addTo(this.map);
|
|
||||||
} else {
|
|
||||||
this.map.removeLayer(marker);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
reorderTrip(oldIndex, newIndex) {
|
|
||||||
const park = this.tripParks.splice(oldIndex, 1)[0];
|
|
||||||
this.tripParks.splice(newIndex, 0, park);
|
|
||||||
this.updateTripDisplay();
|
|
||||||
this.clearRoute();
|
|
||||||
},
|
|
||||||
|
|
||||||
saveTrip() {
|
|
||||||
if (this.tripParks.length === 0) return;
|
|
||||||
|
|
||||||
const tripData = {
|
|
||||||
name: `Trip ${new Date().toLocaleDateString()}`,
|
|
||||||
parks: this.tripParks.map(p => p.id)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save via HTMX
|
|
||||||
htmx.ajax('POST', '/trips/save/', {
|
|
||||||
values: tripData,
|
|
||||||
target: '#saved-trips',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Trip Planner Component Container -->
|
|
||||||
<div x-data="tripPlanner" x-init="init()" style="display: none;"></div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,7 +1,45 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
|
<!-- HTMX + AlpineJS ONLY - NO CUSTOM JAVASCRIPT -->
|
||||||
<!-- Advanced Ride Filters Sidebar -->
|
<!-- Advanced Ride Filters Sidebar -->
|
||||||
<div class="filter-sidebar bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 h-full overflow-y-auto">
|
<div class="filter-sidebar bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 h-full overflow-y-auto"
|
||||||
|
x-data="{
|
||||||
|
sections: {
|
||||||
|
'search-section': true,
|
||||||
|
'basic-section': true,
|
||||||
|
'date-section': false,
|
||||||
|
'height-section': false,
|
||||||
|
'performance-section': false,
|
||||||
|
'relationships-section': false,
|
||||||
|
'coaster-section': false,
|
||||||
|
'sorting-section': false
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Restore section states from localStorage using AlpineJS patterns
|
||||||
|
Object.keys(this.sections).forEach(sectionId => {
|
||||||
|
const state = localStorage.getItem('filter-' + sectionId);
|
||||||
|
if (state !== null) {
|
||||||
|
this.sections[sectionId] = state === 'open';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSection(sectionId) {
|
||||||
|
this.sections[sectionId] = !this.sections[sectionId];
|
||||||
|
localStorage.setItem('filter-' + sectionId, this.sections[sectionId] ? 'open' : 'closed');
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFilter(category, filterName) {
|
||||||
|
// Use HTMX to remove filter
|
||||||
|
htmx.ajax('POST', '/rides/remove-filter/', {
|
||||||
|
values: { category: category, filter: filterName },
|
||||||
|
target: '#filter-results',
|
||||||
|
swap: 'outerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
|
||||||
<!-- Filter Header -->
|
<!-- Filter Header -->
|
||||||
<div class="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 p-4 z-10">
|
<div class="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 p-4 z-10">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -42,7 +80,7 @@
|
|||||||
{{ filter_name }}: {{ filter_value }}
|
{{ filter_name }}: {{ filter_value }}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="ml-1 h-3 w-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
class="ml-1 h-3 w-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
onclick="removeFilter('{{ category }}', '{{ filter_name }}')">
|
@click="removeFilter('{{ category }}', '{{ filter_name }}')">
|
||||||
<i class="fas fa-times text-xs"></i>
|
<i class="fas fa-times text-xs"></i>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
@@ -67,16 +105,17 @@
|
|||||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||||
data-target="search-section">
|
@click="toggleSection('search-section')">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<i class="fas fa-search mr-2 text-gray-500"></i>
|
<i class="fas fa-search mr-2 text-gray-500"></i>
|
||||||
Search
|
Search
|
||||||
</span>
|
</span>
|
||||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-180': sections['search-section'] }"></i>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div id="search-section" class="filter-content p-4 space-y-3">
|
<div id="search-section" class="filter-content p-4 space-y-3" x-show="sections['search-section']" x-transition>
|
||||||
{{ filter_form.search_text.label_tag }}
|
{{ filter_form.search_text.label_tag }}
|
||||||
{{ filter_form.search_text }}
|
{{ filter_form.search_text }}
|
||||||
|
|
||||||
@@ -93,16 +132,17 @@
|
|||||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||||
data-target="basic-section">
|
@click="toggleSection('basic-section')">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<i class="fas fa-info-circle mr-2 text-gray-500"></i>
|
<i class="fas fa-info-circle mr-2 text-gray-500"></i>
|
||||||
Basic Info
|
Basic Info
|
||||||
</span>
|
</span>
|
||||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-180': sections['basic-section'] }"></i>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div id="basic-section" class="filter-content p-4 space-y-4">
|
<div id="basic-section" class="filter-content p-4 space-y-4" x-show="sections['basic-section']" x-transition>
|
||||||
<!-- Categories -->
|
<!-- Categories -->
|
||||||
<div>
|
<div>
|
||||||
{{ filter_form.categories.label_tag }}
|
{{ filter_form.categories.label_tag }}
|
||||||
@@ -127,16 +167,17 @@
|
|||||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||||
data-target="date-section">
|
@click="toggleSection('date-section')">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<i class="fas fa-calendar mr-2 text-gray-500"></i>
|
<i class="fas fa-calendar mr-2 text-gray-500"></i>
|
||||||
Date Ranges
|
Date Ranges
|
||||||
</span>
|
</span>
|
||||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-180': sections['date-section'] }"></i>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div id="date-section" class="filter-content p-4 space-y-4">
|
<div id="date-section" class="filter-content p-4 space-y-4" x-show="sections['date-section']" x-transition>
|
||||||
<!-- Opening Date Range -->
|
<!-- Opening Date Range -->
|
||||||
<div>
|
<div>
|
||||||
{{ filter_form.opening_date_range.label_tag }}
|
{{ filter_form.opening_date_range.label_tag }}
|
||||||
@@ -155,16 +196,17 @@
|
|||||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||||
data-target="height-section">
|
@click="toggleSection('height-section')">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<i class="fas fa-ruler-vertical mr-2 text-gray-500"></i>
|
<i class="fas fa-ruler-vertical mr-2 text-gray-500"></i>
|
||||||
Height & Safety
|
Height & Safety
|
||||||
</span>
|
</span>
|
||||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-180': sections['height-section'] }"></i>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div id="height-section" class="filter-content p-4 space-y-4">
|
<div id="height-section" class="filter-content p-4 space-y-4" x-show="sections['height-section']" x-transition>
|
||||||
<!-- Height Requirements -->
|
<!-- Height Requirements -->
|
||||||
<div>
|
<div>
|
||||||
{{ filter_form.height_requirements.label_tag }}
|
{{ filter_form.height_requirements.label_tag }}
|
||||||
@@ -189,16 +231,17 @@
|
|||||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||||
data-target="performance-section">
|
@click="toggleSection('performance-section')">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<i class="fas fa-tachometer-alt mr-2 text-gray-500"></i>
|
<i class="fas fa-tachometer-alt mr-2 text-gray-500"></i>
|
||||||
Performance
|
Performance
|
||||||
</span>
|
</span>
|
||||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-180': sections['performance-section'] }"></i>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div id="performance-section" class="filter-content p-4 space-y-4">
|
<div id="performance-section" class="filter-content p-4 space-y-4" x-show="sections['performance-section']" x-transition>
|
||||||
<!-- Speed Range -->
|
<!-- Speed Range -->
|
||||||
<div>
|
<div>
|
||||||
{{ filter_form.speed_range.label_tag }}
|
{{ filter_form.speed_range.label_tag }}
|
||||||
@@ -229,16 +272,17 @@
|
|||||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||||
data-target="relationships-section">
|
@click="toggleSection('relationships-section')">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<i class="fas fa-sitemap mr-2 text-gray-500"></i>
|
<i class="fas fa-sitemap mr-2 text-gray-500"></i>
|
||||||
Companies & Models
|
Companies & Models
|
||||||
</span>
|
</span>
|
||||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-180': sections['relationships-section'] }"></i>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div id="relationships-section" class="filter-content p-4 space-y-4">
|
<div id="relationships-section" class="filter-content p-4 space-y-4" x-show="sections['relationships-section']" x-transition>
|
||||||
<!-- Manufacturers -->
|
<!-- Manufacturers -->
|
||||||
<div>
|
<div>
|
||||||
{{ filter_form.manufacturers.label_tag }}
|
{{ filter_form.manufacturers.label_tag }}
|
||||||
@@ -263,16 +307,17 @@
|
|||||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||||
data-target="coaster-section">
|
@click="toggleSection('coaster-section')">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<i class="fas fa-mountain mr-2 text-gray-500"></i>
|
<i class="fas fa-mountain mr-2 text-gray-500"></i>
|
||||||
Roller Coaster Details
|
Roller Coaster Details
|
||||||
</span>
|
</span>
|
||||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-180': sections['coaster-section'] }"></i>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div id="coaster-section" class="filter-content p-4 space-y-4">
|
<div id="coaster-section" class="filter-content p-4 space-y-4" x-show="sections['coaster-section']" x-transition>
|
||||||
<!-- Track Type -->
|
<!-- Track Type -->
|
||||||
<div>
|
<div>
|
||||||
{{ filter_form.track_types.label_tag }}
|
{{ filter_form.track_types.label_tag }}
|
||||||
@@ -324,16 +369,17 @@
|
|||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||||
data-target="sorting-section">
|
@click="toggleSection('sorting-section')">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<i class="fas fa-sort mr-2 text-gray-500"></i>
|
<i class="fas fa-sort mr-2 text-gray-500"></i>
|
||||||
Sorting
|
Sorting
|
||||||
</span>
|
</span>
|
||||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
<i class="fas fa-chevron-down transform transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-180': sections['sorting-section'] }"></i>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div id="sorting-section" class="filter-content p-4 space-y-4">
|
<div id="sorting-section" class="filter-content p-4 space-y-4" x-show="sections['sorting-section']" x-transition>
|
||||||
<!-- Sort By -->
|
<!-- Sort By -->
|
||||||
<div>
|
<div>
|
||||||
{{ filter_form.sort_by.label_tag }}
|
{{ filter_form.sort_by.label_tag }}
|
||||||
@@ -350,116 +396,14 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter JavaScript -->
|
<!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
|
||||||
<script>
|
<div x-data="{
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
init() {
|
||||||
// Initialize collapsible sections
|
// Only essential HTMX error handling as shown in Context7 docs
|
||||||
initializeFilterSections();
|
this.$el.addEventListener('htmx:responseError', (evt) => {
|
||||||
|
if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
|
||||||
// Initialize filter form handlers
|
console.error('HTMX Error:', evt.detail.xhr.status);
|
||||||
initializeFilterForm();
|
|
||||||
});
|
|
||||||
|
|
||||||
function initializeFilterSections() {
|
|
||||||
const toggles = document.querySelectorAll('.filter-toggle');
|
|
||||||
|
|
||||||
toggles.forEach(toggle => {
|
|
||||||
toggle.addEventListener('click', function() {
|
|
||||||
const targetId = this.getAttribute('data-target');
|
|
||||||
const content = document.getElementById(targetId);
|
|
||||||
const chevron = this.querySelector('.fa-chevron-down');
|
|
||||||
|
|
||||||
if (content.style.display === 'none' || content.style.display === '') {
|
|
||||||
content.style.display = 'block';
|
|
||||||
chevron.style.transform = 'rotate(180deg)';
|
|
||||||
localStorage.setItem(`filter-${targetId}`, 'open');
|
|
||||||
} else {
|
|
||||||
content.style.display = 'none';
|
|
||||||
chevron.style.transform = 'rotate(0deg)';
|
|
||||||
localStorage.setItem(`filter-${targetId}`, 'closed');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore section state from localStorage
|
|
||||||
const targetId = toggle.getAttribute('data-target');
|
|
||||||
const content = document.getElementById(targetId);
|
|
||||||
const chevron = toggle.querySelector('.fa-chevron-down');
|
|
||||||
const state = localStorage.getItem(`filter-${targetId}`);
|
|
||||||
|
|
||||||
if (state === 'closed') {
|
|
||||||
content.style.display = 'none';
|
|
||||||
chevron.style.transform = 'rotate(0deg)';
|
|
||||||
} else {
|
|
||||||
content.style.display = 'block';
|
|
||||||
chevron.style.transform = 'rotate(180deg)';
|
|
||||||
}
|
}
|
||||||
});
|
}"></div>
|
||||||
}
|
|
||||||
|
|
||||||
function initializeFilterForm() {
|
|
||||||
const form = document.getElementById('filter-form');
|
|
||||||
if (!form) return;
|
|
||||||
|
|
||||||
// Handle multi-select changes
|
|
||||||
const selects = form.querySelectorAll('select[multiple]');
|
|
||||||
selects.forEach(select => {
|
|
||||||
select.addEventListener('change', function() {
|
|
||||||
// Trigger HTMX update
|
|
||||||
htmx.trigger(form, 'change');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle range inputs
|
|
||||||
const rangeInputs = form.querySelectorAll('input[type="range"], input[type="number"]');
|
|
||||||
rangeInputs.forEach(input => {
|
|
||||||
input.addEventListener('input', function() {
|
|
||||||
// Debounced update
|
|
||||||
clearTimeout(this.updateTimeout);
|
|
||||||
this.updateTimeout = setTimeout(() => {
|
|
||||||
htmx.trigger(form, 'input');
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFilter(category, filterName) {
|
|
||||||
const form = document.getElementById('filter-form');
|
|
||||||
const input = form.querySelector(`[name*="${filterName}"]`);
|
|
||||||
|
|
||||||
if (input) {
|
|
||||||
if (input.type === 'checkbox') {
|
|
||||||
input.checked = false;
|
|
||||||
} else if (input.tagName === 'SELECT') {
|
|
||||||
if (input.multiple) {
|
|
||||||
Array.from(input.options).forEach(option => option.selected = false);
|
|
||||||
} else {
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger form update
|
|
||||||
htmx.trigger(form, 'change');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update filter counts
|
|
||||||
function updateFilterCounts() {
|
|
||||||
const form = document.getElementById('filter-form');
|
|
||||||
const formData = new FormData(form);
|
|
||||||
let activeCount = 0;
|
|
||||||
|
|
||||||
for (let [key, value] of formData.entries()) {
|
|
||||||
if (value && value.trim() !== '') {
|
|
||||||
activeCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const badge = document.querySelector('.filter-count-badge');
|
|
||||||
if (badge) {
|
|
||||||
badge.textContent = activeCount;
|
|
||||||
badge.style.display = activeCount > 0 ? 'inline-flex' : 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -11,25 +11,27 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
selectManufacturer(id, name) {
|
selectManufacturer(id, name) {
|
||||||
const manufacturerInput = document.getElementById('id_manufacturer');
|
// Use AlpineJS $el to scope queries within component
|
||||||
const manufacturerSearch = document.getElementById('id_manufacturer_search');
|
const manufacturerInput = this.$el.querySelector('#id_manufacturer');
|
||||||
const manufacturerResults = document.getElementById('manufacturer-search-results');
|
const manufacturerSearch = this.$el.querySelector('#id_manufacturer_search');
|
||||||
|
const manufacturerResults = this.$el.querySelector('#manufacturer-search-results');
|
||||||
|
|
||||||
if (manufacturerInput) manufacturerInput.value = id;
|
if (manufacturerInput) manufacturerInput.value = id;
|
||||||
if (manufacturerSearch) manufacturerSearch.value = name;
|
if (manufacturerSearch) manufacturerSearch.value = name;
|
||||||
if (manufacturerResults) manufacturerResults.innerHTML = '';
|
if (manufacturerResults) manufacturerResults.innerHTML = '';
|
||||||
|
|
||||||
// Update ride model search to include manufacturer
|
// Update ride model search to include manufacturer
|
||||||
const rideModelSearch = document.getElementById('id_ride_model_search');
|
const rideModelSearch = this.$el.querySelector('#id_ride_model_search');
|
||||||
if (rideModelSearch) {
|
if (rideModelSearch) {
|
||||||
rideModelSearch.setAttribute('hx-include', '[name="manufacturer"]');
|
rideModelSearch.setAttribute('hx-include', '[name="manufacturer"]');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
selectDesigner(id, name) {
|
selectDesigner(id, name) {
|
||||||
const designerInput = document.getElementById('id_designer');
|
// Use AlpineJS $el to scope queries within component
|
||||||
const designerSearch = document.getElementById('id_designer_search');
|
const designerInput = this.$el.querySelector('#id_designer');
|
||||||
const designerResults = document.getElementById('designer-search-results');
|
const designerSearch = this.$el.querySelector('#id_designer_search');
|
||||||
|
const designerResults = this.$el.querySelector('#designer-search-results');
|
||||||
|
|
||||||
if (designerInput) designerInput.value = id;
|
if (designerInput) designerInput.value = id;
|
||||||
if (designerSearch) designerSearch.value = name;
|
if (designerSearch) designerSearch.value = name;
|
||||||
@@ -37,9 +39,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
selectRideModel(id, name) {
|
selectRideModel(id, name) {
|
||||||
const rideModelInput = document.getElementById('id_ride_model');
|
// Use AlpineJS $el to scope queries within component
|
||||||
const rideModelSearch = document.getElementById('id_ride_model_search');
|
const rideModelInput = this.$el.querySelector('#id_ride_model');
|
||||||
const rideModelResults = document.getElementById('ride-model-search-results');
|
const rideModelSearch = this.$el.querySelector('#id_ride_model_search');
|
||||||
|
const rideModelResults = this.$el.querySelector('#ride-model-search-results');
|
||||||
|
|
||||||
if (rideModelInput) rideModelInput.value = id;
|
if (rideModelInput) rideModelInput.value = id;
|
||||||
if (rideModelSearch) rideModelSearch.value = name;
|
if (rideModelSearch) rideModelSearch.value = name;
|
||||||
@@ -47,9 +50,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearAllSearchResults() {
|
clearAllSearchResults() {
|
||||||
const manufacturerResults = document.getElementById('manufacturer-search-results');
|
// Use AlpineJS $el to scope queries within component
|
||||||
const designerResults = document.getElementById('designer-search-results');
|
const manufacturerResults = this.$el.querySelector('#manufacturer-search-results');
|
||||||
const rideModelResults = document.getElementById('ride-model-search-results');
|
const designerResults = this.$el.querySelector('#designer-search-results');
|
||||||
|
const rideModelResults = this.$el.querySelector('#ride-model-search-results');
|
||||||
|
|
||||||
if (manufacturerResults) manufacturerResults.innerHTML = '';
|
if (manufacturerResults) manufacturerResults.innerHTML = '';
|
||||||
if (designerResults) designerResults.innerHTML = '';
|
if (designerResults) designerResults.innerHTML = '';
|
||||||
@@ -57,17 +61,20 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearManufacturerResults() {
|
clearManufacturerResults() {
|
||||||
const manufacturerResults = document.getElementById('manufacturer-search-results');
|
// Use AlpineJS $el to scope queries within component
|
||||||
|
const manufacturerResults = this.$el.querySelector('#manufacturer-search-results');
|
||||||
if (manufacturerResults) manufacturerResults.innerHTML = '';
|
if (manufacturerResults) manufacturerResults.innerHTML = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
clearDesignerResults() {
|
clearDesignerResults() {
|
||||||
const designerResults = document.getElementById('designer-search-results');
|
// Use AlpineJS $el to scope queries within component
|
||||||
|
const designerResults = this.$el.querySelector('#designer-search-results');
|
||||||
if (designerResults) designerResults.innerHTML = '';
|
if (designerResults) designerResults.innerHTML = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
clearRideModelResults() {
|
clearRideModelResults() {
|
||||||
const rideModelResults = document.getElementById('ride-model-search-results');
|
// Use AlpineJS $el to scope queries within component
|
||||||
|
const rideModelResults = this.$el.querySelector('#ride-model-search-results');
|
||||||
if (rideModelResults) rideModelResults.innerHTML = '';
|
if (rideModelResults) rideModelResults.innerHTML = '';
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,327 +1,76 @@
|
|||||||
<script>
|
<!-- HTMX + AlpineJS ONLY - NO CUSTOM JAVASCRIPT -->
|
||||||
document.addEventListener('alpine:init', () => {
|
<div x-data="{
|
||||||
Alpine.data('rideSearch', () => ({
|
searchQuery: new URLSearchParams(window.location.search).get('search') || '',
|
||||||
init() {
|
showSuggestions: false,
|
||||||
// Initialize from URL params
|
selectedIndex: -1,
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
this.searchQuery = urlParams.get('search') || '';
|
|
||||||
|
|
||||||
// Bind to form reset
|
init() {
|
||||||
document.querySelector('form').addEventListener('reset', () => {
|
// Watch for URL changes
|
||||||
this.searchQuery = '';
|
this.$watch('searchQuery', value => {
|
||||||
|
if (value.length >= 2) {
|
||||||
|
this.showSuggestions = true;
|
||||||
|
} else {
|
||||||
this.showSuggestions = false;
|
this.showSuggestions = false;
|
||||||
this.selectedIndex = -1;
|
}
|
||||||
this.cleanup();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle clicks outside suggestions
|
// Handle clicks outside to close suggestions
|
||||||
document.addEventListener('click', (e) => {
|
this.$el.addEventListener('click', (e) => {
|
||||||
if (!e.target.closest('#search-suggestions') && !e.target.closest('#search')) {
|
if (!e.target.closest('#search-suggestions') && !e.target.closest('#search')) {
|
||||||
this.showSuggestions = false;
|
this.showSuggestions = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle HTMX errors
|
|
||||||
document.body.addEventListener('htmx:error', (evt) => {
|
|
||||||
console.error('HTMX Error:', evt.detail.error);
|
|
||||||
this.showError('An error occurred while searching. Please try again.');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store bound handlers for cleanup
|
|
||||||
this.boundHandlers = new Map();
|
|
||||||
|
|
||||||
// Create handler functions
|
|
||||||
const popstateHandler = () => {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
this.searchQuery = urlParams.get('search') || '';
|
|
||||||
this.syncFormWithUrl();
|
|
||||||
};
|
|
||||||
this.boundHandlers.set('popstate', popstateHandler);
|
|
||||||
|
|
||||||
const errorHandler = (evt) => {
|
|
||||||
console.error('HTMX Error:', evt.detail.error);
|
|
||||||
this.showError('An error occurred while searching. Please try again.');
|
|
||||||
};
|
|
||||||
this.boundHandlers.set('htmx:error', errorHandler);
|
|
||||||
|
|
||||||
// Bind event listeners
|
|
||||||
window.addEventListener('popstate', popstateHandler);
|
|
||||||
document.body.addEventListener('htmx:error', errorHandler);
|
|
||||||
|
|
||||||
// Restore filters from localStorage if no URL params exist
|
|
||||||
const savedFilters = localStorage.getItem('rideFilters');
|
|
||||||
|
|
||||||
// Set up destruction handler
|
|
||||||
this.$cleanup = this.performCleanup.bind(this);
|
|
||||||
if (savedFilters) {
|
|
||||||
const filters = JSON.parse(savedFilters);
|
|
||||||
Object.entries(filters).forEach(([key, value]) => {
|
|
||||||
const input = document.querySelector(`[name="${key}"]`);
|
|
||||||
if (input) input.value = value;
|
|
||||||
});
|
|
||||||
// Trigger search with restored filters
|
|
||||||
document.querySelector('form').dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up filter persistence
|
|
||||||
document.querySelector('form').addEventListener('change', (e) => {
|
|
||||||
this.saveFilters();
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
showSuggestions: false,
|
|
||||||
loading: false,
|
|
||||||
searchQuery: '',
|
|
||||||
suggestionTimeout: null,
|
|
||||||
|
|
||||||
// Save current filters to localStorage
|
|
||||||
saveFilters() {
|
|
||||||
const form = document.querySelector('form');
|
|
||||||
const formData = new FormData(form);
|
|
||||||
const filters = {};
|
|
||||||
for (let [key, value] of formData.entries()) {
|
|
||||||
if (value) filters[key] = value;
|
|
||||||
}
|
|
||||||
localStorage.setItem('rideFilters', JSON.stringify(filters));
|
|
||||||
},
|
|
||||||
|
|
||||||
// Clear all filters
|
|
||||||
clearFilters() {
|
|
||||||
document.querySelectorAll('form select, form input').forEach(el => {
|
|
||||||
el.value = '';
|
|
||||||
});
|
|
||||||
localStorage.removeItem('rideFilters');
|
|
||||||
document.querySelector('form').dispatchEvent(new Event('change'));
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get search suggestions with request tracking
|
|
||||||
lastRequestId: 0,
|
|
||||||
currentRequest: null,
|
|
||||||
|
|
||||||
getSearchSuggestions() {
|
|
||||||
if (this.searchQuery.length < 2) {
|
|
||||||
this.showSuggestions = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel any pending request
|
|
||||||
if (this.currentRequest) {
|
|
||||||
this.currentRequest.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestId = ++this.lastRequestId;
|
|
||||||
const controller = new AbortController();
|
|
||||||
this.currentRequest = controller;
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
|
|
||||||
|
|
||||||
this.fetchSuggestions(controller, requestId, () => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (this.currentRequest === controller) {
|
|
||||||
this.currentRequest = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
fetchSuggestions(controller, requestId) {
|
|
||||||
const parkSlug = document.querySelector('input[name="park_slug"]')?.value;
|
|
||||||
const queryParams = {
|
|
||||||
q: this.searchQuery
|
|
||||||
};
|
|
||||||
if (parkSlug) {
|
|
||||||
queryParams.park_slug = parkSlug;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create temporary form for HTMX request
|
|
||||||
const tempForm = document.createElement('form');
|
|
||||||
tempForm.setAttribute('hx-get', '/rides/search-suggestions/');
|
|
||||||
tempForm.setAttribute('hx-vals', JSON.stringify(queryParams));
|
|
||||||
tempForm.setAttribute('hx-trigger', 'submit');
|
|
||||||
tempForm.setAttribute('hx-swap', 'none');
|
|
||||||
|
|
||||||
// Add request ID header simulation
|
|
||||||
tempForm.setAttribute('hx-headers', JSON.stringify({
|
|
||||||
'X-Request-ID': requestId.toString()
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Handle abort signal
|
|
||||||
if (controller.signal.aborted) {
|
|
||||||
this.handleSuggestionError(new Error('AbortError'), requestId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const abortHandler = () => {
|
|
||||||
if (document.body.contains(tempForm)) {
|
|
||||||
document.body.removeChild(tempForm);
|
|
||||||
}
|
|
||||||
this.handleSuggestionError(new Error('AbortError'), requestId);
|
|
||||||
};
|
|
||||||
controller.signal.addEventListener('abort', abortHandler);
|
|
||||||
|
|
||||||
tempForm.addEventListener('htmx:afterRequest', (event) => {
|
|
||||||
controller.signal.removeEventListener('abort', abortHandler);
|
|
||||||
|
|
||||||
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
|
|
||||||
this.handleSuggestionResponse(event.detail.xhr, requestId);
|
|
||||||
} else {
|
|
||||||
this.handleSuggestionError(new Error(`HTTP error! status: ${event.detail.xhr.status}`), requestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.body.contains(tempForm)) {
|
|
||||||
document.body.removeChild(tempForm);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tempForm.addEventListener('htmx:error', (event) => {
|
|
||||||
controller.signal.removeEventListener('abort', abortHandler);
|
|
||||||
this.handleSuggestionError(new Error(`HTTP error! status: ${event.detail.xhr.status || 'unknown'}`), requestId);
|
|
||||||
|
|
||||||
if (document.body.contains(tempForm)) {
|
|
||||||
document.body.removeChild(tempForm);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.appendChild(tempForm);
|
|
||||||
htmx.trigger(tempForm, 'submit');
|
|
||||||
},
|
|
||||||
|
|
||||||
handleSuggestionResponse(xhr, requestId) {
|
|
||||||
if (requestId === this.lastRequestId && this.searchQuery === document.getElementById('search').value) {
|
|
||||||
const html = xhr.responseText || '';
|
|
||||||
const suggestionsEl = document.getElementById('search-suggestions');
|
|
||||||
suggestionsEl.innerHTML = html;
|
|
||||||
this.showSuggestions = Boolean(html.trim());
|
|
||||||
|
|
||||||
this.updateAriaAttributes(suggestionsEl);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
updateAriaAttributes(suggestionsEl) {
|
|
||||||
const searchInput = document.getElementById('search');
|
|
||||||
searchInput.setAttribute('aria-expanded', this.showSuggestions.toString());
|
|
||||||
searchInput.setAttribute('aria-controls', 'search-suggestions');
|
|
||||||
if (this.showSuggestions) {
|
|
||||||
suggestionsEl.setAttribute('role', 'listbox');
|
|
||||||
suggestionsEl.querySelectorAll('button').forEach(btn => {
|
|
||||||
btn.setAttribute('role', 'option');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
handleSuggestionError(error, requestId) {
|
|
||||||
if (error.name === 'AbortError') {
|
|
||||||
console.warn('Search suggestion request timed out or cancelled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Error fetching suggestions:', error);
|
|
||||||
if (requestId === this.lastRequestId) {
|
|
||||||
const suggestionsEl = document.getElementById('search-suggestions');
|
|
||||||
suggestionsEl.innerHTML = `
|
|
||||||
<div class="p-2 text-sm text-red-600 dark:text-red-400" role="alert">
|
|
||||||
Failed to load suggestions. Please try again.
|
|
||||||
</div>`;
|
|
||||||
this.showSuggestions = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Handle input changes with debounce
|
|
||||||
handleInput() {
|
handleInput() {
|
||||||
clearTimeout(this.suggestionTimeout);
|
// HTMX will handle the actual search request
|
||||||
this.suggestionTimeout = setTimeout(() => {
|
if (this.searchQuery.length >= 2) {
|
||||||
this.getSearchSuggestions();
|
this.showSuggestions = true;
|
||||||
}, 200);
|
} else {
|
||||||
},
|
|
||||||
|
|
||||||
// Handle suggestion selection
|
|
||||||
// Sync form with URL parameters
|
|
||||||
syncFormWithUrl() {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const form = document.querySelector('form');
|
|
||||||
|
|
||||||
// Clear existing values
|
|
||||||
form.querySelectorAll('input, select').forEach(el => {
|
|
||||||
if (el.type !== 'hidden') el.value = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set values from URL
|
|
||||||
urlParams.forEach((value, key) => {
|
|
||||||
const input = form.querySelector(`[name="${key}"]`);
|
|
||||||
if (input) input.value = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger form update
|
|
||||||
form.dispatchEvent(new Event('change'));
|
|
||||||
},
|
|
||||||
|
|
||||||
// Cleanup resources
|
|
||||||
cleanup() {
|
|
||||||
clearTimeout(this.suggestionTimeout);
|
|
||||||
this.showSuggestions = false;
|
this.showSuggestions = false;
|
||||||
localStorage.removeItem('rideFilters');
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
selectSuggestion(text) {
|
selectSuggestion(text) {
|
||||||
this.searchQuery = text;
|
this.searchQuery = text;
|
||||||
this.showSuggestions = false;
|
this.showSuggestions = false;
|
||||||
document.getElementById('search').value = text;
|
// Update the search input
|
||||||
|
this.$refs.searchInput.value = text;
|
||||||
// Update URL with search parameter
|
// Trigger form change for HTMX
|
||||||
const url = new URL(window.location);
|
this.$refs.searchForm.dispatchEvent(new Event('change'));
|
||||||
url.searchParams.set('search', text);
|
|
||||||
window.history.pushState({}, '', url);
|
|
||||||
|
|
||||||
document.querySelector('form').dispatchEvent(new Event('change'));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Handle keyboard navigation
|
|
||||||
// Show error message
|
|
||||||
showError(message) {
|
|
||||||
const searchInput = document.getElementById('search');
|
|
||||||
const errorDiv = document.createElement('div');
|
|
||||||
errorDiv.className = 'text-red-600 text-sm mt-1';
|
|
||||||
errorDiv.textContent = message;
|
|
||||||
searchInput.parentNode.appendChild(errorDiv);
|
|
||||||
setTimeout(() => errorDiv.remove(), 3000);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Handle keyboard navigation
|
|
||||||
handleKeydown(e) {
|
handleKeydown(e) {
|
||||||
const suggestions = document.querySelectorAll('#search-suggestions button');
|
const suggestions = this.$el.querySelectorAll('#search-suggestions button');
|
||||||
if (!suggestions.length) return;
|
if (!suggestions.length) return;
|
||||||
|
|
||||||
const currentIndex = Array.from(suggestions).findIndex(el => el === document.activeElement);
|
|
||||||
|
|
||||||
switch(e.key) {
|
switch(e.key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (currentIndex < 0) {
|
if (this.selectedIndex < suggestions.length - 1) {
|
||||||
suggestions[0].focus();
|
this.selectedIndex++;
|
||||||
this.selectedIndex = 0;
|
suggestions[this.selectedIndex].focus();
|
||||||
} else if (currentIndex < suggestions.length - 1) {
|
|
||||||
suggestions[currentIndex + 1].focus();
|
|
||||||
this.selectedIndex = currentIndex + 1;
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (currentIndex > 0) {
|
if (this.selectedIndex > 0) {
|
||||||
suggestions[currentIndex - 1].focus();
|
this.selectedIndex--;
|
||||||
this.selectedIndex = currentIndex - 1;
|
suggestions[this.selectedIndex].focus();
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('search').focus();
|
this.$refs.searchInput.focus();
|
||||||
this.selectedIndex = -1;
|
this.selectedIndex = -1;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
this.showSuggestions = false;
|
this.showSuggestions = false;
|
||||||
this.selectedIndex = -1;
|
this.selectedIndex = -1;
|
||||||
document.getElementById('search').blur();
|
this.$refs.searchInput.blur();
|
||||||
break;
|
break;
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
if (document.activeElement.tagName === 'BUTTON') {
|
if (e.target.tagName === 'BUTTON') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.selectSuggestion(document.activeElement.dataset.text);
|
this.selectSuggestion(e.target.dataset.text);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Tab':
|
case 'Tab':
|
||||||
@@ -329,37 +78,45 @@ document.addEventListener('alpine:init', () => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}"
|
||||||
});
|
@click.outside="showSuggestions = false">
|
||||||
},
|
|
||||||
|
|
||||||
performCleanup() {
|
<!-- Search Input with HTMX -->
|
||||||
// Remove all bound event listeners
|
<input
|
||||||
this.boundHandlers.forEach(this.removeEventHandler.bind(this));
|
x-ref="searchInput"
|
||||||
this.boundHandlers.clear();
|
x-model="searchQuery"
|
||||||
|
@input="handleInput()"
|
||||||
|
@keydown="handleKeydown($event)"
|
||||||
|
hx-get="/rides/search-suggestions/"
|
||||||
|
hx-trigger="input changed delay:200ms"
|
||||||
|
hx-target="#search-suggestions"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-include="[name='park_slug']"
|
||||||
|
:aria-expanded="showSuggestions"
|
||||||
|
aria-controls="search-suggestions"
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
id="search"
|
||||||
|
placeholder="Search rides..."
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
|
||||||
// Cancel any pending requests
|
<!-- Suggestions Container -->
|
||||||
if (this.currentRequest) {
|
<div
|
||||||
this.currentRequest.abort();
|
x-show="showSuggestions"
|
||||||
this.currentRequest = null;
|
x-transition
|
||||||
}
|
id="search-suggestions"
|
||||||
|
role="listbox"
|
||||||
|
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<!-- HTMX will populate this -->
|
||||||
|
</div>
|
||||||
|
|
||||||
// Clear any pending timeouts
|
<!-- Form Reference for HTMX -->
|
||||||
if (this.suggestionTimeout) {
|
<form x-ref="searchForm" style="display: none;">
|
||||||
clearTimeout(this.suggestionTimeout);
|
<!-- Hidden form for HTMX reference -->
|
||||||
}
|
</form>
|
||||||
},
|
</div>
|
||||||
|
|
||||||
removeEventHandler(handler, event) {
|
|
||||||
if (event === 'popstate') {
|
|
||||||
window.removeEventListener(event, handler);
|
|
||||||
} else {
|
|
||||||
document.body.removeEventListener(event, handler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- HTMX Loading Indicator Styles -->
|
<!-- HTMX Loading Indicator Styles -->
|
||||||
<style>
|
<style>
|
||||||
@@ -368,10 +125,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
transition: opacity 200ms ease-in;
|
transition: opacity 200ms ease-in;
|
||||||
}
|
}
|
||||||
.htmx-request .htmx-indicator {
|
.htmx-request .htmx-indicator {
|
||||||
opacity: 1
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enhanced Loading Indicator */
|
|
||||||
.loading-indicator {
|
.loading-indicator {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
@@ -396,60 +152,14 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
|
||||||
// Initialize request timeout management
|
<div x-data="{
|
||||||
const timeouts = new Map();
|
init() {
|
||||||
|
// Only essential HTMX error handling as shown in Context7 docs
|
||||||
// Handle request start
|
this.$el.addEventListener('htmx:responseError', (evt) => {
|
||||||
document.addEventListener('htmx:beforeRequest', function(evt) {
|
if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
|
||||||
const timestamp = document.querySelector('.loading-timestamp');
|
console.error('HTMX Error:', evt.detail.xhr.status);
|
||||||
if (timestamp) {
|
|
||||||
timestamp.textContent = new Date().toLocaleTimeString();
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// Set timeout for request
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
evt.detail.xhr.abort();
|
|
||||||
showError('Request timed out. Please try again.');
|
|
||||||
}, 10000); // 10s timeout
|
|
||||||
|
|
||||||
timeouts.set(evt.detail.xhr, timeoutId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle request completion
|
|
||||||
document.addEventListener('htmx:afterRequest', function(evt) {
|
|
||||||
const timeoutId = timeouts.get(evt.detail.xhr);
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
timeouts.delete(evt.detail.xhr);
|
|
||||||
}
|
}
|
||||||
|
}"></div>
|
||||||
if (!evt.detail.successful) {
|
|
||||||
showError('Failed to update results. Please try again.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle errors
|
|
||||||
function showError(message) {
|
|
||||||
const indicator = document.querySelector('.loading-indicator');
|
|
||||||
if (indicator) {
|
|
||||||
indicator.innerHTML = `
|
|
||||||
<div class="flex items-center text-red-100">
|
|
||||||
<i class="mr-2 fas fa-exclamation-circle"></i>
|
|
||||||
<span>${message}</span>
|
|
||||||
</div>`;
|
|
||||||
setTimeout(() => {
|
|
||||||
indicator.innerHTML = originalIndicatorContent;
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store original indicator content
|
|
||||||
const originalIndicatorContent = document.querySelector('.loading-indicator')?.innerHTML;
|
|
||||||
|
|
||||||
// Reset loading state when navigating away
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
timeouts.forEach(timeoutId => clearTimeout(timeoutId));
|
|
||||||
timeouts.clear();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -15,26 +15,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="{
|
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="rideFormData()">
|
||||||
status: '{{ form.instance.status|default:'OPERATING' }}',
|
|
||||||
clearResults(containerId) {
|
|
||||||
const container = document.getElementById(containerId);
|
|
||||||
if (container && !container.contains(event.target)) {
|
|
||||||
container.querySelector('[id$=search-results]').innerHTML = '';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleStatusChange(event) {
|
|
||||||
this.status = event.target.value;
|
|
||||||
if (this.status === 'CLOSING') {
|
|
||||||
document.getElementById('id_closing_date').required = true;
|
|
||||||
} else {
|
|
||||||
document.getElementById('id_closing_date').required = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
showClosingDate() {
|
|
||||||
return ['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(this.status);
|
|
||||||
}
|
|
||||||
}">
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{% if not park %}
|
{% if not park %}
|
||||||
@@ -242,4 +223,41 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('rideFormData', () => ({
|
||||||
|
status: '{{ form.instance.status|default:"OPERATING" }}',
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Watch for status changes on the status select element
|
||||||
|
this.$watch('status', (value) => {
|
||||||
|
const closingDateField = this.$el.querySelector('#id_closing_date');
|
||||||
|
if (closingDateField) {
|
||||||
|
closingDateField.required = value === 'CLOSING';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearResults(containerId) {
|
||||||
|
// Use AlpineJS $el to find container within component scope
|
||||||
|
const container = this.$el.querySelector(`#${containerId}`);
|
||||||
|
if (container) {
|
||||||
|
const resultsDiv = container.querySelector('[id$="search-results"]');
|
||||||
|
if (resultsDiv) {
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleStatusChange(event) {
|
||||||
|
this.status = event.target.value;
|
||||||
|
},
|
||||||
|
|
||||||
|
showClosingDate() {
|
||||||
|
return ['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(this.status);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user