mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 05:51:08 -05:00
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:
@@ -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();
|
||||
},
|
||||
|
||||
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);
|
||||
initializeMap() {
|
||||
// Use HTMX to load map component
|
||||
htmx.ajax('GET', '/maps/location-widget/', {
|
||||
target: '#locationMap',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
},
|
||||
|
||||
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);
|
||||
|
||||
@@ -238,150 +52,160 @@ document.addEventListener('alpine:init', () => {
|
||||
}, 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'
|
||||
});
|
||||
|
||||
document.body.appendChild(tempForm);
|
||||
htmx.trigger(tempForm, 'submit');
|
||||
},
|
||||
|
||||
selectLocation(result) {
|
||||
if (!result) return;
|
||||
selectLocation(lat, lng, displayName, address) {
|
||||
// Update coordinates
|
||||
this.$refs.latitude.value = lat;
|
||||
this.$refs.longitude.value = lng;
|
||||
|
||||
try {
|
||||
const lat = parseFloat(result.lat);
|
||||
const lon = parseFloat(result.lon);
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
throw new Error('Invalid coordinates in search result');
|
||||
// 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 || '';
|
||||
}
|
||||
|
||||
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);
|
||||
// Update search input
|
||||
this.$refs.searchInput.value = displayName;
|
||||
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 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();
|
||||
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'
|
||||
});
|
||||
}
|
||||
this.marker = L.marker([lat, lng]).addTo(this.map);
|
||||
this.map.setView([lat, lng], 13);
|
||||
},
|
||||
}"
|
||||
@click.outside="showResults = false">
|
||||
|
||||
updateLocation(lat, lng, data) {
|
||||
try {
|
||||
const normalized = this.validateCoordinates(lat, lng);
|
||||
{# 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;">
|
||||
|
||||
// Update coordinates using AlpineJS $el
|
||||
this.$el.querySelector('#latitude').value = normalized.lat;
|
||||
this.$el.querySelector('#longitude').value = normalized.lng;
|
||||
<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>
|
||||
|
||||
// Update marker
|
||||
this.addMarker(normalized.lat, normalized.lng);
|
||||
{# 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>
|
||||
|
||||
// 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.');
|
||||
{# 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
}"></div>
|
||||
|
||||
Reference in New Issue
Block a user