Refactor templates to utilize AlpineJS for state management and interactions, replacing custom JavaScript. Updated navigation links for parks and rides, streamlined mobile filter functionality, and enhanced advanced search features. Removed legacy JavaScript code for improved performance and maintainability.

This commit is contained in:
pacnpal
2025-09-26 13:43:14 -04:00
parent 9b2124867a
commit 8c0c3df21a
28 changed files with 114 additions and 9888 deletions

View File

@@ -288,114 +288,21 @@
</div>
</div>
<!-- AlpineJS State Management -->
<script>
{# Enhanced Mobile-First AlpineJS State Management #}
function parkListState() {
return {
showFilters: window.innerWidth >= 1024, // Show on desktop by default
viewMode: '{{ view_mode }}',
searchQuery: '{{ search_query }}',
isLoading: false,
error: null,
init() {
// Handle responsive filter visibility with better mobile UX
this.handleResize();
window.addEventListener('resize', this.debounce(() => this.handleResize(), 250));
// Enhanced HTMX events with better mobile feedback
document.addEventListener('htmx:beforeRequest', () => {
this.setLoading(true);
this.error = null;
});
document.addEventListener('htmx:afterRequest', (event) => {
this.setLoading(false);
// Scroll to top of results on mobile after filter changes
if (window.innerWidth < 768 && event.detail.target?.id === 'park-results') {
this.scrollToResults();
}
});
document.addEventListener('htmx:responseError', () => {
this.setLoading(false);
this.showError('Failed to load results. Please check your connection and try again.');
});
// Handle mobile viewport changes (orientation, virtual keyboard)
this.handleMobileViewport();
},
handleResize() {
if (window.innerWidth >= 1024) {
this.showFilters = true;
}
// Auto-hide filters on mobile after interaction for better UX
// Keep current state but could add auto-hide logic here
},
handleMobileViewport() {
// Handle mobile viewport changes for better UX
if ('visualViewport' in window) {
window.visualViewport.addEventListener('resize', () => {
// Handle virtual keyboard appearance/disappearance
document.documentElement.style.setProperty(
'--viewport-height',
`${window.visualViewport.height}px`
);
});
}
},
scrollToResults() {
// Smooth scroll to results on mobile for better UX
const resultsElement = document.getElementById('park-results');
if (resultsElement) {
resultsElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
},
setLoading(loading) {
this.isLoading = loading;
// Disable form interactions while loading for better UX
const formElements = document.querySelectorAll('select, input, button');
formElements.forEach(el => {
el.disabled = loading;
});
},
showError(message) {
this.error = message;
// Auto-clear error after 5 seconds
setTimeout(() => {
this.error = null;
}, 5000);
console.error(message);
},
clearAllFilters() {
// Add loading state for better UX
this.setLoading(true);
window.location.href = '{% url "parks:park_list" %}';
},
// Utility function for better performance
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
}
}
</script>
{% endblock %}
<!-- AlpineJS Component Definition (HTMX + AlpineJS Only) -->
<div x-data="{
showFilters: window.innerWidth >= 1024,
viewMode: '{{ view_mode }}',
searchQuery: '{{ search_query }}',
isLoading: false,
error: null,
clearAllFilters() {
window.location.href = '{% url \"parks:park_list\" %}';
}
}"
@htmx:before-request="isLoading = true; error = null"
@htmx:after-request="isLoading = false"
@htmx:response-error="isLoading = false; error = 'Failed to load results'"
style="display: none;">
<!-- Park list functionality handled by AlpineJS + HTMX -->
</div>
{% endblock %}

View File

@@ -306,517 +306,8 @@
<!-- Sortable JS for drag & drop -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<script>
// Road Trip Planner class
class TripPlanner {
constructor() {
this.map = null;
this.tripParks = [];
this.allParks = [];
this.parkMarkers = {};
this.routeControl = null;
this.showingAllParks = false;
this.init();
}
init() {
this.initMap();
this.loadAllParks();
this.initDragDrop();
this.bindEvents();
}
initMap() {
// Initialize the map
this.map = L.map('map-container', {
center: [39.8283, -98.5795],
zoom: 4,
zoomControl: false
});
// Add custom zoom control
L.control.zoom({
position: 'bottomright'
}).addTo(this.map);
// Add tile layers with dark mode support
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
});
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors, © CARTO'
});
// Set initial tiles based on theme
if (document.documentElement.classList.contains('dark')) {
darkTiles.addTo(this.map);
} else {
lightTiles.addTo(this.map);
}
// Listen for theme changes
this.observeThemeChanges(lightTiles, darkTiles);
}
observeThemeChanges(lightTiles, darkTiles) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
if (document.documentElement.classList.contains('dark')) {
this.map.removeLayer(lightTiles);
this.map.addLayer(darkTiles);
} else {
this.map.removeLayer(darkTiles);
this.map.addLayer(lightTiles);
}
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
}
loadAllParks() {
// Create temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.setAttribute('hx-get', '{{ map_api_urls.locations }}');
tempForm.setAttribute('hx-vals', JSON.stringify({types: 'park', limit: 1000}));
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
tempForm.addEventListener('htmx:afterRequest', (event) => {
try {
const data = JSON.parse(event.detail.xhr.responseText);
if (data.status === 'success' && data.data.locations) {
this.allParks = data.data.locations;
}
} catch (error) {
console.error('Failed to load parks:', error);
}
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
}
initDragDrop() {
// Make trip parks sortable
new Sortable(document.getElementById('trip-parks'), {
animation: 150,
ghostClass: 'drag-over',
onEnd: (evt) => {
this.reorderTripParks(evt.oldIndex, evt.newIndex);
}
});
}
bindEvents() {
// Handle park search results
document.addEventListener('htmx:afterRequest', (event) => {
if (event.target.id === 'park-search-results') {
this.handleSearchResults();
}
});
}
handleSearchResults() {
const results = document.getElementById('park-search-results');
if (results.children.length > 0) {
results.classList.remove('hidden');
} else {
results.classList.add('hidden');
}
}
addParkToTrip(parkData) {
// Check if park already in trip
if (this.tripParks.find(p => p.id === parkData.id)) {
return;
}
this.tripParks.push(parkData);
this.updateTripDisplay();
this.updateTripMarkers();
this.updateButtons();
// Hide search results
document.getElementById('park-search-results').classList.add('hidden');
document.getElementById('park-search').value = '';
}
removeParkFromTrip(parkId) {
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
this.updateTripDisplay();
this.updateTripMarkers();
this.updateButtons();
if (this.routeControl) {
this.map.removeControl(this.routeControl);
this.routeControl = null;
}
}
updateTripDisplay() {
const container = document.getElementById('trip-parks');
const emptyState = document.getElementById('empty-trip');
if (this.tripParks.length === 0) {
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
// Clear existing parks (except empty state)
Array.from(container.children).forEach(child => {
if (child.id !== 'empty-trip') {
child.remove();
}
});
// Add trip parks
this.tripParks.forEach((park, index) => {
const parkElement = this.createTripParkElement(park, index);
container.appendChild(parkElement);
});
}
createTripParkElement(park, index) {
const div = document.createElement('div');
div.className = 'park-card draggable-item';
div.innerHTML = `
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<div class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold">
${index + 1}
</div>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 dark:text-white truncate">
${park.name}
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 truncate">
${park.formatted_location || 'Location not specified'}
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
class="text-red-500 hover:text-red-700 p-1">
<i class="fas fa-times"></i>
</button>
<i class="fas fa-grip-vertical text-gray-400 cursor-grab"></i>
</div>
</div>
`;
return div;
}
updateTripMarkers() {
// Clear existing trip markers
Object.values(this.parkMarkers).forEach(marker => {
this.map.removeLayer(marker);
});
this.parkMarkers = {};
// Add markers for trip parks
this.tripParks.forEach((park, index) => {
const marker = this.createTripMarker(park, index);
this.parkMarkers[park.id] = marker;
marker.addTo(this.map);
});
// Fit map to show all trip parks
if (this.tripParks.length > 0) {
this.fitRoute();
}
}
createTripMarker(park, index) {
let markerClass = 'waypoint-stop';
if (index === 0) markerClass = 'waypoint-start';
if (index === this.tripParks.length - 1 && this.tripParks.length > 1) markerClass = 'waypoint-end';
const icon = L.divIcon({
className: `waypoint-marker ${markerClass}`,
html: `<div class="waypoint-marker-inner">${index + 1}</div>`,
iconSize: [30, 30],
iconAnchor: [15, 15]
});
const marker = L.marker([park.latitude, park.longitude], { icon });
const popupContent = `
<div class="text-center">
<h3 class="font-semibold mb-2">${park.name}</h3>
<div class="text-sm text-gray-600 mb-2">Stop ${index + 1}</div>
${park.ride_count ? `<div class="text-sm text-gray-600 mb-2">${park.ride_count} rides</div>` : ''}
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
class="px-3 py-1 text-sm text-red-600 border border-red-600 rounded hover:bg-red-50">
Remove from Trip
</button>
</div>
`;
marker.bindPopup(popupContent);
return marker;
}
reorderTripParks(oldIndex, newIndex) {
const park = this.tripParks.splice(oldIndex, 1)[0];
this.tripParks.splice(newIndex, 0, park);
this.updateTripDisplay();
this.updateTripMarkers();
// Clear route to force recalculation
if (this.routeControl) {
this.map.removeControl(this.routeControl);
this.routeControl = null;
}
}
optimizeRoute() {
if (this.tripParks.length < 2) return;
const parkIds = this.tripParks.map(p => p.id);
// Create temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.setAttribute('hx-post', '{% url "parks:htmx_optimize_route" %}');
tempForm.setAttribute('hx-vals', JSON.stringify({ park_ids: parkIds }));
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = '{{ csrf_token }}';
tempForm.appendChild(csrfInput);
tempForm.addEventListener('htmx:afterRequest', (event) => {
try {
const data = JSON.parse(event.detail.xhr.responseText);
if (data.status === 'success' && data.optimized_order) {
// Reorder parks based on optimization
const optimizedParks = data.optimized_order.map(id =>
this.tripParks.find(p => p.id === id)
).filter(Boolean);
this.tripParks = optimizedParks;
this.updateTripDisplay();
this.updateTripMarkers();
}
} catch (error) {
console.error('Route optimization failed:', error);
}
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
}
calculateRoute() {
if (this.tripParks.length < 2) return;
// Remove existing route
if (this.routeControl) {
this.map.removeControl(this.routeControl);
}
const waypoints = this.tripParks.map(park =>
L.latLng(park.latitude, park.longitude)
);
this.routeControl = L.Routing.control({
waypoints: waypoints,
routeWhileDragging: false,
addWaypoints: false,
createMarker: () => null, // Don't create default markers
lineOptions: {
styles: [{ color: '#3b82f6', weight: 4, opacity: 0.7 }]
}
}).addTo(this.map);
this.routeControl.on('routesfound', (e) => {
const route = e.routes[0];
this.updateTripSummary(route);
});
}
updateTripSummary(route) {
if (!route) return;
const totalDistance = (route.summary.totalDistance / 1609.34).toFixed(1); // Convert to miles
const totalTime = this.formatDuration(route.summary.totalTime);
const totalRides = this.tripParks.reduce((sum, park) => sum + (park.ride_count || 0), 0);
document.getElementById('total-distance').textContent = totalDistance;
document.getElementById('total-time').textContent = totalTime;
document.getElementById('total-parks').textContent = this.tripParks.length;
document.getElementById('total-rides').textContent = totalRides;
document.getElementById('trip-summary').classList.remove('hidden');
}
formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
fitRoute() {
if (this.tripParks.length === 0) return;
const group = new L.featureGroup(Object.values(this.parkMarkers));
this.map.fitBounds(group.getBounds().pad(0.1));
}
toggleAllParks() {
// Implementation for showing/hiding all parks on the map
const button = document.getElementById('toggle-parks');
const icon = button.querySelector('i');
if (this.showingAllParks) {
// Hide all parks
this.showingAllParks = false;
icon.className = 'mr-1 fas fa-eye';
button.innerHTML = icon.outerHTML + 'Show All Parks';
} else {
// Show all parks
this.showingAllParks = true;
icon.className = 'mr-1 fas fa-eye-slash';
button.innerHTML = icon.outerHTML + 'Hide All Parks';
this.displayAllParks();
}
}
displayAllParks() {
// Add markers for all parks (implementation depends on requirements)
this.allParks.forEach(park => {
if (!this.parkMarkers[park.id]) {
const marker = L.marker([park.latitude, park.longitude], {
icon: L.divIcon({
className: 'location-marker location-marker-park',
html: '<div class="location-marker-inner">🎢</div>',
iconSize: [20, 20],
iconAnchor: [10, 10]
})
});
marker.bindPopup(`
<div class="text-center">
<h3 class="font-semibold mb-2">${park.name}</h3>
<button onclick="tripPlanner.addParkToTrip(${JSON.stringify(park).replace(/"/g, '&quot;')})"
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
Add to Trip
</button>
</div>
`);
marker.addTo(this.map);
this.parkMarkers[`all_${park.id}`] = marker;
}
});
}
updateButtons() {
const optimizeBtn = document.getElementById('optimize-route');
const calculateBtn = document.getElementById('calculate-route');
const hasEnoughParks = this.tripParks.length >= 2;
optimizeBtn.disabled = !hasEnoughParks;
calculateBtn.disabled = !hasEnoughParks;
}
clearTrip() {
this.tripParks = [];
this.updateTripDisplay();
this.updateTripMarkers();
this.updateButtons();
if (this.routeControl) {
this.map.removeControl(this.routeControl);
this.routeControl = null;
}
document.getElementById('trip-summary').classList.add('hidden');
}
saveTrip() {
if (this.tripParks.length === 0) return;
const tripName = prompt('Enter a name for this trip:');
if (!tripName) return;
// Create temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.setAttribute('hx-post', '{% url "parks:htmx_save_trip" %}');
tempForm.setAttribute('hx-vals', JSON.stringify({
name: tripName,
park_ids: this.tripParks.map(p => p.id)
}));
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = '{{ csrf_token }}';
tempForm.appendChild(csrfInput);
tempForm.addEventListener('htmx:afterRequest', (event) => {
try {
const data = JSON.parse(event.detail.xhr.responseText);
if (data.status === 'success') {
alert('Trip saved successfully!');
// Refresh saved trips using HTMX
htmx.trigger(document.getElementById('saved-trips'), 'refresh');
} else {
alert('Failed to save trip: ' + (data.message || 'Unknown error'));
}
} catch (error) {
console.error('Save trip failed:', error);
alert('Failed to save trip');
}
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
}
}
// Global function for adding parks from search results
window.addParkToTrip = function(parkData) {
window.tripPlanner.addParkToTrip(parkData);
};
// Initialize trip planner when page loads
document.addEventListener('DOMContentLoaded', function() {
window.tripPlanner = new TripPlanner();
// Hide search results when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('#park-search') && !e.target.closest('#park-search-results')) {
document.getElementById('park-search-results').classList.add('hidden');
}
});
});
</script>
<!-- AlpineJS Trip Planner Component (HTMX + AlpineJS Only) -->
<div x-data="tripPlanner" x-init="initMap()" style="display: none;">
<!-- Trip planner functionality handled by AlpineJS + HTMX -->
</div>
{% endblock %}