mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 13:31:18 -05:00
412 lines
17 KiB
HTML
412 lines
17 KiB
HTML
{% load static %}
|
|
|
|
<style>
|
|
/* Ensure map container and its elements stay below other UI elements */
|
|
.leaflet-pane,
|
|
.leaflet-tile,
|
|
.leaflet-marker-icon,
|
|
.leaflet-marker-shadow,
|
|
.leaflet-tile-container,
|
|
.leaflet-pane > svg,
|
|
.leaflet-pane > canvas,
|
|
.leaflet-zoom-box,
|
|
.leaflet-image-layer,
|
|
.leaflet-layer {
|
|
z-index: 1 !important;
|
|
}
|
|
.leaflet-control {
|
|
z-index: 2 !important;
|
|
}
|
|
</style>
|
|
|
|
<div class="p-4 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50"
|
|
x-data="locationWidget({
|
|
submissionId: '{{ submission.id }}',
|
|
initialData: {
|
|
city: '{{ submission.changes.city|default:"" }}',
|
|
state: '{{ submission.changes.state|default:"" }}',
|
|
country: '{{ submission.changes.country|default:"" }}',
|
|
postal_code: '{{ submission.changes.postal_code|default:"" }}',
|
|
street_address: '{{ submission.changes.street_address|default:"" }}',
|
|
latitude: '{{ submission.changes.latitude|default:"" }}',
|
|
longitude: '{{ submission.changes.longitude|default:"" }}'
|
|
}
|
|
})"
|
|
x-init="init()">
|
|
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-300">Location</h3>
|
|
|
|
<div class="location-widget">
|
|
{# Search Form #}
|
|
<div class="relative mb-4">
|
|
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Search Location
|
|
</label>
|
|
<input type="text"
|
|
x-model="searchQuery"
|
|
@input.debounce.300ms="handleSearch()"
|
|
@click.outside="showSearchResults = false"
|
|
class="relative w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
|
|
placeholder="Search for a location..."
|
|
autocomplete="off"
|
|
style="z-index: 10;">
|
|
<div x-show="showSearchResults"
|
|
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">
|
|
<span x-text="result.address?.city ? result.address.city + ', ' : ''"></span>
|
|
<span x-text="result.address?.country || ''"></span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div x-show="searchResults.length === 0 && searchQuery.length > 0"
|
|
class="p-2 text-gray-500 dark:text-gray-400">
|
|
No results found
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# Map Container #}
|
|
<div class="relative mb-4" style="z-index: 1;">
|
|
<div x-ref="mapContainer"
|
|
class="h-[300px] w-full rounded-lg border border-gray-300 dark:border-gray-600"></div>
|
|
</div>
|
|
|
|
{# Location Form Fields #}
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div>
|
|
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Street Address
|
|
</label>
|
|
<input type="text"
|
|
name="street_address"
|
|
x-model="formData.street_address"
|
|
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input">
|
|
</div>
|
|
<div>
|
|
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
City
|
|
</label>
|
|
<input type="text"
|
|
name="city"
|
|
x-model="formData.city"
|
|
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input">
|
|
</div>
|
|
<div>
|
|
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
State/Region
|
|
</label>
|
|
<input type="text"
|
|
name="state"
|
|
x-model="formData.state"
|
|
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input">
|
|
</div>
|
|
<div>
|
|
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Country
|
|
</label>
|
|
<input type="text"
|
|
name="country"
|
|
x-model="formData.country"
|
|
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input">
|
|
</div>
|
|
<div>
|
|
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Postal Code
|
|
</label>
|
|
<input type="text"
|
|
name="postal_code"
|
|
x-model="formData.postal_code"
|
|
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input">
|
|
</div>
|
|
</div>
|
|
|
|
{# Hidden Coordinate Fields #}
|
|
<div class="hidden">
|
|
<input type="hidden" name="latitude" x-model="formData.latitude">
|
|
<input type="hidden" name="longitude" x-model="formData.longitude">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('locationWidget', (config) => ({
|
|
submissionId: config.submissionId,
|
|
formData: { ...config.initialData },
|
|
searchQuery: '',
|
|
searchResults: [],
|
|
showSearchResults: false,
|
|
map: null,
|
|
marker: null,
|
|
|
|
init() {
|
|
// Set initial search query if location exists
|
|
if (this.formData.street_address || this.formData.city) {
|
|
const parts = [
|
|
this.formData.street_address,
|
|
this.formData.city,
|
|
this.formData.state,
|
|
this.formData.country
|
|
].filter(Boolean);
|
|
this.searchQuery = parts.join(', ');
|
|
}
|
|
|
|
// Initialize map when component is ready
|
|
this.$nextTick(() => {
|
|
this.initMap();
|
|
});
|
|
},
|
|
|
|
normalizeCoordinate(value, maxDigits, decimalPlaces) {
|
|
if (!value) return null;
|
|
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() {
|
|
if (!this.$refs.mapContainer) {
|
|
console.error('Map container not found');
|
|
return;
|
|
}
|
|
|
|
// If map already exists, remove it
|
|
if (this.map) {
|
|
this.map.remove();
|
|
this.map = null;
|
|
this.marker = null;
|
|
}
|
|
|
|
// Create new map
|
|
this.map = L.map(this.$refs.mapContainer);
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors'
|
|
}).addTo(this.map);
|
|
|
|
// Initialize with existing coordinates if available
|
|
if (this.formData.latitude && this.formData.longitude) {
|
|
try {
|
|
const normalized = this.validateCoordinates(this.formData.latitude, this.formData.longitude);
|
|
this.map.setView([normalized.lat, normalized.lng], 13);
|
|
this.addMarker(normalized.lat, normalized.lng);
|
|
} catch (error) {
|
|
console.error('Invalid initial coordinates:', error);
|
|
this.map.setView([0, 0], 2);
|
|
}
|
|
} else {
|
|
this.map.setView([0, 0], 2);
|
|
}
|
|
|
|
// Handle map clicks
|
|
this.map.on('click', (e) => {
|
|
this.handleMapClick(e.latlng.lat, e.latlng.lng);
|
|
});
|
|
},
|
|
|
|
addMarker(lat, lng) {
|
|
if (this.marker) {
|
|
this.marker.remove();
|
|
}
|
|
this.marker = L.marker([lat, lng]).addTo(this.map);
|
|
this.map.setView([lat, lng], 13);
|
|
},
|
|
|
|
async handleMapClick(lat, lng) {
|
|
try {
|
|
const normalized = this.validateCoordinates(lat, lng);
|
|
|
|
// Use HTMX for reverse geocoding
|
|
const tempForm = document.createElement('form');
|
|
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');
|
|
|
|
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(normalized.lat, normalized.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');
|
|
|
|
} catch (error) {
|
|
console.error('Location update failed:', error);
|
|
alert(error.message || 'Failed to update location. Please try again.');
|
|
}
|
|
},
|
|
|
|
updateLocation(lat, lng, data) {
|
|
try {
|
|
const normalized = this.validateCoordinates(lat, lng);
|
|
|
|
// Update coordinates
|
|
this.formData.latitude = normalized.lat;
|
|
this.formData.longitude = normalized.lng;
|
|
|
|
// Update marker
|
|
this.addMarker(normalized.lat, normalized.lng);
|
|
|
|
// Update form fields with English names where available
|
|
const address = data.address || {};
|
|
this.formData.street_address = `${address.house_number || ''} ${address.road || address.street || ''}`.trim() || '';
|
|
this.formData.city = address.city || address.town || address.village || '';
|
|
this.formData.state = address.state || address.region || '';
|
|
this.formData.country = address.country || '';
|
|
this.formData.postal_code = address.postcode || '';
|
|
|
|
// Update search input
|
|
const locationParts = [
|
|
this.formData.street_address,
|
|
this.formData.city,
|
|
this.formData.state,
|
|
this.formData.country
|
|
].filter(Boolean);
|
|
this.searchQuery = locationParts.join(', ');
|
|
} catch (error) {
|
|
console.error('Location update failed:', error);
|
|
alert(error.message || 'Failed to update location. Please try again.');
|
|
}
|
|
},
|
|
|
|
handleSearch() {
|
|
const query = this.searchQuery.trim();
|
|
|
|
if (!query) {
|
|
this.showSearchResults = false;
|
|
return;
|
|
}
|
|
|
|
// Use HTMX for location search
|
|
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);
|
|
|
|
if (data.results && data.results.length > 0) {
|
|
this.searchResults = data.results;
|
|
this.showSearchResults = true;
|
|
} else {
|
|
this.searchResults = [];
|
|
this.showSearchResults = true;
|
|
}
|
|
} catch (error) {
|
|
console.error('Search failed:', error);
|
|
this.searchResults = [];
|
|
this.showSearchResults = false;
|
|
}
|
|
} else {
|
|
console.error('Search request failed');
|
|
this.searchResults = [];
|
|
this.showSearchResults = false;
|
|
}
|
|
document.body.removeChild(tempForm);
|
|
});
|
|
|
|
document.body.appendChild(tempForm);
|
|
htmx.trigger(tempForm, 'submit');
|
|
},
|
|
|
|
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 = this.validateCoordinates(lat, lon);
|
|
|
|
// Create a normalized address object
|
|
const address = {
|
|
name: result.display_name || result.name || '',
|
|
address: {
|
|
house_number: result.address ? result.address.house_number : '',
|
|
road: result.address ? (result.address.road || result.address.street) : '',
|
|
city: result.address ? (result.address.city || result.address.town || result.address.village) : '',
|
|
state: result.address ? (result.address.state || result.address.region) : '',
|
|
country: result.address ? result.address.country : '',
|
|
postcode: result.address ? result.address.postcode : ''
|
|
}
|
|
};
|
|
|
|
this.updateLocation(normalized.lat, normalized.lng, address);
|
|
this.showSearchResults = false;
|
|
this.searchQuery = address.name;
|
|
} catch (error) {
|
|
console.error('Location selection failed:', error);
|
|
alert(error.message || 'Failed to select location. Please try again.');
|
|
}
|
|
}
|
|
}));
|
|
});
|
|
</script>
|