Refactor location widget to utilize Alpine.js for state management and HTMX for AJAX interactions. Removed legacy JavaScript functions and streamlined event handling for improved user experience.

This commit is contained in:
pacnpal
2025-09-26 15:42:07 -04:00
parent de8b6f67a3
commit acc8308fd2

View File

@@ -1,5 +1,6 @@
{% load static %}
<!-- HTMX + AlpineJS ONLY - NO CUSTOM JAVASCRIPT -->
<style>
/* Ensure map container and its elements stay below other UI elements */
.leaflet-pane,
@@ -19,212 +20,25 @@
}
</style>
<div class="location-widget" id="locationWidget" x-data="locationWidget()">
{# Search Form #}
<div class="relative mb-4">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Search Location
</label>
<input type="text"
id="locationSearch"
class="relative w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search for a location..."
autocomplete="off"
style="z-index: 10;">
<div x-show="showResults && searchResults.length > 0"
x-transition
style="position: absolute; top: 100%; left: 0; right: 0; z-index: 1000;"
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>
{# Map Container #}
<div class="relative mb-4" style="z-index: 1;">
<div id="locationMap" class="h-[300px] w-full rounded-lg border border-gray-300 dark:border-gray-600"></div>
</div>
{# Location Form Fields #}
<div class="relative grid grid-cols-1 gap-4 md:grid-cols-2" style="z-index: 10;">
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Street Address
</label>
<input type="text"
name="street_address"
id="streetAddress"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.street_address.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
City
</label>
<input type="text"
name="city"
id="city"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.city.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
State/Region
</label>
<input type="text"
name="state"
id="state"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.state.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Country
</label>
<input type="text"
name="country"
id="country"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.country.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Postal Code
</label>
<input type="text"
name="postal_code"
id="postalCode"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.postal_code.value|default:'' }}">
</div>
</div>
{# Hidden Coordinate Fields #}
<div class="hidden">
<input type="hidden" name="latitude" id="latitude" value="{{ form.latitude.value|default:'' }}">
<input type="hidden" name="longitude" id="longitude" value="{{ form.longitude.value|default:'' }}">
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('locationWidget', () => ({
map: null,
marker: null,
searchTimeout: null,
<div class="location-widget" id="locationWidget"
x-data="{
searchResults: [],
showResults: false,
searchTimeout: null,
init() {
this.$nextTick(() => {
this.initMap();
this.setupEventListeners();
// Initialize map via HTMX
this.initializeMap();
},
initializeMap() {
// Use HTMX to load map component
htmx.ajax('GET', '/maps/location-widget/', {
target: '#locationMap',
swap: 'innerHTML'
});
},
normalizeCoordinate(value, maxDigits, decimalPlaces) {
try {
const rounded = Number(value).toFixed(decimalPlaces);
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) {
console.error('Coordinate normalization failed:', error);
return null;
}
},
validateCoordinates(lat, lng) {
const normalizedLat = this.normalizeCoordinate(lat, 9, 6);
const normalizedLng = this.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 };
},
initMap() {
this.map = L.map('locationMap').setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).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);
});
}
// Click outside handler
document.addEventListener('click', (e) => {
if (!this.$el.contains(e.target)) {
this.showResults = false;
}
});
},
handleMapClick(lat, lng) {
try {
const normalized = this.validateCoordinates(lat, lng);
this.reverseGeocode(normalized.lat, normalized.lng);
} catch (error) {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
}
},
handleSearchInput(query) {
clearTimeout(this.searchTimeout);
@@ -232,156 +46,166 @@ document.addEventListener('alpine:init', () => {
this.showResults = false;
return;
}
this.searchTimeout = setTimeout(() => {
this.searchLocation(query.trim());
}, 300);
},
handleFormSubmit(e) {
const lat = this.$el.querySelector('#latitude').value;
const lng = this.$el.querySelector('#longitude').value;
if (lat && lng) {
try {
this.validateCoordinates(lat, lng);
} catch (error) {
e.preventDefault();
alert(error.message || 'Invalid coordinates. Please check the location.');
}
}
},
reverseGeocode(lat, lng) {
const tempForm = document.createElement('form');
tempForm.style.display = 'none';
tempForm.setAttribute('hx-get', '/parks/search/reverse-geocode/');
tempForm.setAttribute('hx-vals', JSON.stringify({ lat, lon: lng }));
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
tempForm.addEventListener('htmx:afterRequest', (event) => {
if (event.detail.successful) {
try {
const data = JSON.parse(event.detail.xhr.responseText);
if (data.error) {
throw new Error(data.error);
}
this.updateLocation(lat, lng, data);
} catch (error) {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
}
} else {
console.error('Geocoding request failed');
alert('Failed to update location. Please try again.');
}
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
},
searchLocation(query) {
const tempForm = document.createElement('form');
tempForm.style.display = 'none';
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');
tempForm.addEventListener('htmx:afterRequest', (event) => {
if (event.detail.successful) {
try {
const data = JSON.parse(event.detail.xhr.responseText);
this.searchResults = data.results || [];
this.showResults = true;
} catch (error) {
console.error('Search failed:', error);
this.searchResults = [];
this.showResults = false;
}
} else {
console.error('Search request failed');
this.searchResults = [];
this.showResults = false;
}
document.body.removeChild(tempForm);
// Use HTMX for location search
htmx.ajax('GET', '/parks/search/location/', {
values: { q: query },
target: '#search-results-container',
swap: 'innerHTML'
});
},
selectLocation(lat, lng, displayName, address) {
// Update coordinates
this.$refs.latitude.value = lat;
this.$refs.longitude.value = lng;
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
},
selectLocation(result) {
if (!result) return;
// Update address fields
if (address) {
this.$refs.streetAddress.value = address.street || '';
this.$refs.city.value = address.city || '';
this.$refs.state.value = address.state || '';
this.$refs.country.value = address.country || '';
this.$refs.postalCode.value = address.postal_code || '';
}
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 = 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) {
console.error('Location selection failed:', error);
alert(error.message || 'Failed to select location. Please try again.');
}
// Update search input
this.$refs.searchInput.value = displayName;
this.showResults = false;
// Update map via HTMX
htmx.ajax('POST', '/maps/update-marker/', {
values: { lat: lat, lng: lng },
target: '#locationMap',
swap: 'none'
});
},
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.');
}
handleMapClick(lat, lng) {
// Use HTMX for reverse geocoding
htmx.ajax('GET', '/parks/search/reverse-geocode/', {
values: { lat: lat, lon: lng },
target: '#location-form-fields',
swap: 'none'
});
}
}));
});
</script>
}"
@click.outside="showResults = false">
{# Search Form #}
<div class="relative mb-4">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Search Location
</label>
<input type="text"
x-ref="searchInput"
@input="handleSearchInput($event.target.value)"
hx-get="/parks/search/location/"
hx-trigger="input changed delay:300ms"
hx-target="#search-results-container"
hx-swap="innerHTML"
class="relative w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search for a location..."
autocomplete="off"
style="z-index: 10;">
<div id="search-results-container"
x-show="showResults"
x-transition
style="position: absolute; top: 100%; left: 0; right: 0; z-index: 1000;"
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">
<!-- Search results will be populated here via HTMX -->
</div>
</div>
{# Map Container #}
<div class="relative mb-4" style="z-index: 1;">
<div id="locationMap" class="h-[300px] w-full rounded-lg border border-gray-300 dark:border-gray-600">
<!-- 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>
{# Location Form Fields #}
<div id="location-form-fields" class="relative grid grid-cols-1 gap-4 md:grid-cols-2" style="z-index: 10;">
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Street Address
</label>
<input type="text"
name="street_address"
x-ref="streetAddress"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.street_address.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
City
</label>
<input type="text"
name="city"
x-ref="city"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.city.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
State/Region
</label>
<input type="text"
name="state"
x-ref="state"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.state.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Country
</label>
<input type="text"
name="country"
x-ref="country"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.country.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Postal Code
</label>
<input type="text"
name="postal_code"
x-ref="postalCode"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.postal_code.value|default:'' }}">
</div>
</div>
{# Hidden Coordinate Fields #}
<div class="hidden">
<input type="hidden" name="latitude" x-ref="latitude" value="{{ form.latitude.value|default:'' }}">
<input type="hidden" name="longitude" x-ref="longitude" value="{{ form.longitude.value|default:'' }}">
</div>
</div>
<!-- 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);
}
});
}
}"></div>