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:
pacnpal
2025-09-26 15:25:12 -04:00
parent c437ddbf28
commit de8b6f67a3
8 changed files with 706 additions and 1172 deletions

View File

@@ -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!!!

View File

@@ -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>

View File

@@ -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,300 +113,275 @@
</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() {
try { this.$nextTick(() => {
// Convert to string-3 with exact decimal places this.initMap();
const rounded = Number(value).toFixed(decimalPlaces); this.setupEventListeners();
});
},
// Convert to string-3 without decimal point for digit counting normalizeCoordinate(value, maxDigits, decimalPlaces) {
const strValue = rounded.replace('.', '').replace('-', '');
// Remove trailing zeros
const strippedValue = strValue.replace(/0+$/, '');
// If total digits exceed maxDigits, round further
if (strippedValue.length > maxDigits) {
return Number(Number(value).toFixed(decimalPlaces - 1));
}
// Return the string-3 representation to preserve exact decimal places
return rounded;
} catch (error) {
console.error('Coordinate normalization failed:', error);
return null;
}
}
function validateCoordinates(lat, lng) {
// Normalize coordinates
const normalizedLat = normalizeCoordinate(lat, 9, 6);
const normalizedLng = normalizeCoordinate(lng, 10, 6);
if (normalizedLat === null || normalizedLng === null) {
throw new Error('Invalid coordinate format');
}
const parsedLat = parseFloat(normalizedLat);
const parsedLng = parseFloat(normalizedLng);
if (parsedLat < -90 || parsedLat > 90) {
throw new Error('Latitude must be between -90 and 90 degrees.');
}
if (parsedLng < -180 || parsedLng > 180) {
throw new Error('Longitude must be between -180 and 180 degrees.');
}
return { lat: normalizedLat, lng: normalizedLng };
}
// Initialize map
function initMap() {
map = L.map('locationMap').setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Initialize with existing coordinates if available
const initialLat = document.getElementById('latitude').value;
const initialLng = document.getElementById('longitude').value;
if (initialLat && initialLng) {
try { try {
const normalized = validateCoordinates(initialLat, initialLng); const rounded = Number(value).toFixed(decimalPlaces);
addMarker(normalized.lat, normalized.lng); const strValue = rounded.replace('.', '').replace('-', '');
const strippedValue = strValue.replace(/0+$/, '');
if (strippedValue.length > maxDigits) {
return Number(Number(value).toFixed(decimalPlaces - 1));
}
return rounded;
} catch (error) { } catch (error) {
console.error('Invalid initial coordinates:', error); console.error('Coordinate normalization failed:', error);
return null;
} }
} },
// Handle map clicks - HTMX version validateCoordinates(lat, lng) {
map.on('click', function(e) { const normalizedLat = this.normalizeCoordinate(lat, 9, 6);
try { const normalizedLng = this.normalizeCoordinate(lng, 10, 6);
const normalized = validateCoordinates(e.latlng.lat, e.latlng.lng);
// Create a temporary form for HTMX request if (normalizedLat === null || normalizedLng === null) {
const tempForm = document.createElement('form'); throw new Error('Invalid coordinate format');
tempForm.style.display = 'none'; }
tempForm.setAttribute('hx-get', '/parks/search/reverse-geocode/');
tempForm.setAttribute('hx-vals', JSON.stringify({
lat: normalized.lat,
lon: normalized.lng
}));
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
// Add event listener for HTMX response const parsedLat = parseFloat(normalizedLat);
tempForm.addEventListener('htmx:afterRequest', function(event) { const parsedLng = parseFloat(normalizedLng);
if (event.detail.successful) {
try { if (parsedLat < -90 || parsedLat > 90) {
const data = JSON.parse(event.detail.xhr.responseText); throw new Error('Latitude must be between -90 and 90 degrees.');
if (data.error) { }
throw new Error(data.error); if (parsedLng < -180 || parsedLng > 180) {
} throw new Error('Longitude must be between -180 and 180 degrees.');
updateLocation(normalized.lat, normalized.lng, data); }
} catch (error) {
console.error('Location update failed:', error); return { lat: normalizedLat, lng: normalizedLng };
alert(error.message || 'Failed to update location. Please try again.'); },
}
} else { initMap() {
console.error('Geocoding request failed'); this.map = L.map('locationMap').setView([0, 0], 2);
alert('Failed to update location. Please try again.');
} L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
// Clean up temporary form attribution: '© OpenStreetMap contributors'
document.body.removeChild(tempForm); }).addTo(this.map);
// Initialize with existing coordinates if available
const initialLat = this.$el.querySelector('#latitude').value;
const initialLng = this.$el.querySelector('#longitude').value;
if (initialLat && initialLng) {
try {
const normalized = this.validateCoordinates(initialLat, initialLng);
this.addMarker(normalized.lat, normalized.lng);
} catch (error) {
console.error('Invalid initial coordinates:', error);
}
}
// Handle map clicks using AlpineJS approach
this.map.on('click', (e) => {
this.handleMapClick(e.latlng.lat, e.latlng.lng);
});
},
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);
}); });
}
document.body.appendChild(tempForm); // Click outside handler
htmx.trigger(tempForm, 'submit'); 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) { } 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.');
} }
}); },
}
// Initialize map handleSearchInput(query) {
initMap(); clearTimeout(this.searchTimeout);
// Handle location search - HTMX version if (!query.trim()) {
searchInput.addEventListener('input', function() { this.showResults = false;
clearTimeout(searchTimeout); return;
const query = this.value.trim(); }
if (!query) { this.searchTimeout = setTimeout(() => {
searchResults.classList.add('hidden'); this.searchLocation(query.trim());
return; }, 300);
} },
searchTimeout = setTimeout(function() { handleFormSubmit(e) {
// Create a temporary form for HTMX request 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/location/'); tempForm.setAttribute('hx-get', '/parks/search/reverse-geocode/');
tempForm.setAttribute('hx-vals', JSON.stringify({ tempForm.setAttribute('hx-vals', JSON.stringify({ lat, lon: lng }));
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);
if (data.error) {
if (data.results && data.results.length > 0) { throw new Error(data.error);
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');
} }
this.updateLocation(lat, lng, data);
} catch (error) { } catch (error) {
console.error('Search failed:', error); console.error('Location update failed:', error);
searchResults.innerHTML = '<div class="p-2 text-red-500 dark:text-red-400">Search failed. Please try again.</div>'; alert(error.message || 'Failed to update location. Please try again.');
searchResults.classList.remove('hidden');
} }
} else { } else {
console.error('Search request failed'); console.error('Geocoding request failed');
searchResults.innerHTML = '<div class="p-2 text-red-500 dark:text-red-400">Search failed. Please try again.</div>'; alert('Failed to update location. Please try again.');
searchResults.classList.remove('hidden');
} }
// 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 searchLocation(query) {
document.addEventListener('click', function(e) { const tempForm = document.createElement('form');
if (!searchResults.contains(e.target) && e.target !== searchInput) { tempForm.style.display = 'none';
searchResults.classList.add('hidden'); tempForm.setAttribute('hx-get', '/parks/search/location/');
} tempForm.setAttribute('hx-vals', JSON.stringify({ q: query }));
}); tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
function addMarker(lat, lng) { tempForm.addEventListener('htmx:afterRequest', (event) => {
if (marker) { if (event.detail.successful) {
marker.remove(); try {
} const data = JSON.parse(event.detail.xhr.responseText);
marker = L.marker([lat, lng]).addTo(map); this.searchResults = data.results || [];
map.setView([lat, lng], 13); this.showResults = true;
} } catch (error) {
console.error('Search failed:', error);
function updateLocation(lat, lng, data) { this.searchResults = [];
try { this.showResults = false;
const normalized = validateCoordinates(lat, lng); }
} else {
// Update coordinates console.error('Search request failed');
document.getElementById('latitude').value = normalized.lat; this.searchResults = [];
document.getElementById('longitude').value = normalized.lng; this.showResults = false;
// 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;
try {
const lat = parseFloat(result.lat);
const lon = parseFloat(result.lon);
if (isNaN(lat) || isNaN(lon)) {
throw new Error('Invalid coordinates in search result');
}
const normalized = validateCoordinates(lat, lon);
// Create a normalized address object
const address = {
name: result.display_name || result.name || '',
address: {
house_number: result.house_number || (result.address && result.address.house_number) || '',
road: result.street || (result.address && (result.address.road || result.address.street)) || '',
city: result.city || (result.address && (result.address.city || result.address.town || result.address.village)) || '',
state: result.state || (result.address && (result.address.state || result.address.region)) || '',
country: result.country || (result.address && result.address.country) || '',
postcode: result.postal_code || (result.address && result.address.postcode) || ''
} }
}; document.body.removeChild(tempForm);
});
updateLocation(normalized.lat, normalized.lng, address); document.body.appendChild(tempForm);
searchResults.classList.add('hidden'); htmx.trigger(tempForm, 'submit');
searchInput.value = address.name; },
} catch (error) {
console.error('Location selection failed:', error);
alert(error.message || 'Failed to select location. Please try again.');
}
}
// Add form submit handler selectLocation(result) {
const form = document.querySelector('form'); if (!result) return;
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 lat = parseFloat(result.lat);
const lon = parseFloat(result.lon);
if (isNaN(lat) || isNaN(lon)) {
throw new Error('Invalid coordinates in search result');
}
const normalized = this.validateCoordinates(lat, lon);
const address = {
name: result.display_name || result.name || '',
address: {
house_number: result.house_number || (result.address && result.address.house_number) || '',
road: result.street || (result.address && (result.address.road || result.address.street)) || '',
city: result.city || (result.address && (result.address.city || result.address.town || result.address.village)) || '',
state: result.state || (result.address && (result.address.state || result.address.region)) || '',
country: result.country || (result.address && result.address.country) || '',
postcode: result.postal_code || (result.address && result.address.postcode) || ''
}
};
this.updateLocation(normalized.lat, normalized.lng, address);
this.showResults = false;
this.$el.querySelector('#locationSearch').value = address.name;
} catch (error) { } catch (error) {
e.preventDefault(); console.error('Location selection failed:', error);
alert(error.message || 'Invalid coordinates. Please check the location.'); 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);
},
updateLocation(lat, lng, data) {
try {
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) {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
} }
} }
}); }));
}); });
</script> </script>

View File

@@ -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">
<i class="fas fa-route text-3xl mb-3"></i> <div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p>Add parks to start planning your trip</p> <i class="fas fa-route text-3xl mb-3"></i>
<p class="text-sm mt-1">Search above or click parks on the map</p> <p>Add parks to start planning your trip</p>
</div> <p class="text-sm mt-1">Search above or click parks on the map</p>
</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,26 +336,32 @@
<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> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -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> <!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
document.addEventListener('alpine:init', () => { <div x-data="{
Alpine.data('tripPlanner', () => ({ init() {
map: null, // Only essential HTMX error handling as shown in Context7 docs
tripParks: [], this.$el.addEventListener('htmx:responseError', (evt) => {
allParks: [], if (evt.detail.xhr.status === 404 || evt.detail.xhr.status === 500) {
showAllParks: false, console.error('HTMX Error:', evt.detail.xhr.status);
routeControl: null,
parkMarkers: {},
init() {
this.initMap();
this.setupSortable();
},
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() { }"></div>
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);
}
});
}
}
},
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 %}

View File

@@ -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>

View File

@@ -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 = '';
} }
})); }));

View File

@@ -1,365 +1,122 @@
<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;
}
});
// Handle clicks outside to close suggestions
this.$el.addEventListener('click', (e) => {
if (!e.target.closest('#search-suggestions') && !e.target.closest('#search')) {
this.showSuggestions = false;
}
});
},
handleInput() {
// HTMX will handle the actual search request
if (this.searchQuery.length >= 2) {
this.showSuggestions = true;
} else {
this.showSuggestions = false;
}
},
selectSuggestion(text) {
this.searchQuery = text;
this.showSuggestions = false;
// Update the search input
this.$refs.searchInput.value = text;
// Trigger form change for HTMX
this.$refs.searchForm.dispatchEvent(new Event('change'));
},
handleKeydown(e) {
const suggestions = this.$el.querySelectorAll('#search-suggestions button');
if (!suggestions.length) return;
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
if (this.selectedIndex < suggestions.length - 1) {
this.selectedIndex++;
suggestions[this.selectedIndex].focus();
}
break;
case 'ArrowUp':
e.preventDefault();
if (this.selectedIndex > 0) {
this.selectedIndex--;
suggestions[this.selectedIndex].focus();
} else {
this.$refs.searchInput.focus();
this.selectedIndex = -1;
}
break;
case 'Escape':
this.showSuggestions = false; this.showSuggestions = false;
this.selectedIndex = -1; this.selectedIndex = -1;
this.cleanup(); this.$refs.searchInput.blur();
}); break;
case 'Enter':
// Handle clicks outside suggestions if (e.target.tagName === 'BUTTON') {
document.addEventListener('click', (e) => { e.preventDefault();
if (!e.target.closest('#search-suggestions') && !e.target.closest('#search')) { this.selectSuggestion(e.target.dataset.text);
this.showSuggestions = false;
} }
}); break;
case 'Tab':
// 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; this.showSuggestions = false;
return; break;
}
// 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() {
clearTimeout(this.suggestionTimeout);
this.suggestionTimeout = setTimeout(() => {
this.getSearchSuggestions();
}, 200);
},
// 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;
localStorage.removeItem('rideFilters');
},
selectSuggestion(text) {
this.searchQuery = text;
this.showSuggestions = false;
document.getElementById('search').value = text;
// Update URL with search parameter
const url = new URL(window.location);
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) {
const suggestions = document.querySelectorAll('#search-suggestions button');
if (!suggestions.length) return;
const currentIndex = Array.from(suggestions).findIndex(el => el === document.activeElement);
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
if (currentIndex < 0) {
suggestions[0].focus();
this.selectedIndex = 0;
} else if (currentIndex < suggestions.length - 1) {
suggestions[currentIndex + 1].focus();
this.selectedIndex = currentIndex + 1;
}
break;
case 'ArrowUp':
e.preventDefault();
if (currentIndex > 0) {
suggestions[currentIndex - 1].focus();
this.selectedIndex = currentIndex - 1;
} else {
document.getElementById('search').focus();
this.selectedIndex = -1;
}
break;
case 'Escape':
this.showSuggestions = false;
this.selectedIndex = -1;
document.getElementById('search').blur();
break;
case 'Enter':
if (document.activeElement.tagName === 'BUTTON') {
e.preventDefault();
this.selectSuggestion(document.activeElement.dataset.text);
}
break;
case 'Tab':
this.showSuggestions = false;
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(); });
} }
}"></div>
// 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);
}
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>

View File

@@ -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 %}