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

@@ -124,7 +124,81 @@
{% endblock %}
{% 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 -->
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
<div>
@@ -167,7 +241,7 @@
</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">
<!-- Search results will be populated here -->
<!-- Search results will be populated here via HTMX -->
</div>
</div>
@@ -175,61 +249,80 @@
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
<button id="clear-trip"
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
<button class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
@click="clearTrip()">
<i class="mr-1 fas fa-trash"></i>Clear All
</button>
</div>
<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">
<i class="fas fa-route text-3xl mb-3"></i>
<p>Add parks to start planning your trip</p>
<p class="text-sm mt-1">Search above or click parks on the map</p>
</div>
<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>
<p>Add parks to start planning your trip</p>
<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 class="mt-4 space-y-2">
<button id="optimize-route"
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()" :disabled="tripParks.length < 2">
<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"
@click="optimizeRoute()"
:disabled="tripParks.length < 2">
<i class="mr-2 fas fa-route"></i>Optimize Route
</button>
<button id="calculate-route"
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()" :disabled="tripParks.length < 2">
<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"
@click="calculateRoute()"
:disabled="tripParks.length < 2">
<i class="mr-2 fas fa-map"></i>Calculate Route
</button>
</div>
</div>
<!-- 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>
<div class="trip-stats">
<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>
<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>
<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>
<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>
</div>
<div class="mt-4">
<button id="save-trip"
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
<button class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
@click="saveTrip()">
<i class="mr-2 fas fa-save"></i>Save Trip
</button>
@@ -243,26 +336,32 @@
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Route Map</h3>
<div class="flex gap-2">
<button id="fit-route"
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="fitRoute()">
<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"
hx-post="/maps/fit-route/"
hx-vals='{"parks": "{{ tripParks|join:"," }}"}'
hx-target="#map-container"
hx-swap="none">
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
</button>
<button id="toggle-parks"
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="toggleAllParks()">
<i class="mr-1 fas fa-eye"></i><span x-text="showAllParks ? 'Hide Parks' : 'Show All Parks'">Show All Parks</span>
<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"
@click="showAllParks = !showAllParks"
hx-post="/maps/toggle-parks/"
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>
</div>
</div>
<div id="map-container" class="map-container"></div>
<!-- Map Loading Indicator -->
<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="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>
<div id="map-container" class="map-container">
<!-- Map will be loaded via HTMX -->
<div class="flex items-center justify-center h-full bg-gray-100 dark:bg-gray-800 rounded-lg">
<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>
<p class="text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
</div>
</div>
</div>
</div>
@@ -286,7 +385,7 @@
hx-get="{% url 'parks:htmx_saved_trips' %}"
hx-trigger="load"
hx-indicator="#trips-loading">
<!-- Saved trips will be loaded here -->
<!-- Saved trips will be loaded here via HTMX -->
</div>
<div id="trips-loading" class="htmx-indicator text-center py-4">
@@ -299,255 +398,19 @@
{% endblock %}
{% block extra_js %}
<!-- Leaflet JS -->
<!-- External libraries for map functionality only -->
<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>
<!-- 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: {},
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();
<!-- HTMX Error Handling (Minimal JavaScript as allowed by Context7 docs) -->
<div x-data="{
init() {
// Only essential HTMX error handling as shown in Context7 docs
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);
}
},
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);
}
});
}
}
},
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>
});
}
}"></div>
{% endblock %}