mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 02:11:10 -05:00
okay fine
This commit is contained in:
@@ -90,7 +90,7 @@
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
<span>Parks</span>
|
||||
</a>
|
||||
<a href="{% url 'parks:all_rides' %}" class="nav-link">
|
||||
<a href="{% url 'rides:ride_list' %}" class="nav-link">
|
||||
<i class="fas fa-rocket"></i>
|
||||
<span>Rides</span>
|
||||
</a>
|
||||
|
||||
17
templates/location/partials/search_results.html
Normal file
17
templates/location/partials/search_results.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<div class="search-results-container">
|
||||
{% if results %}
|
||||
{% for result in results %}
|
||||
<div class="p-2 cursor-pointer hover:bg-gray-100"
|
||||
data-action="click->location-map#selectLocation"
|
||||
data-result="{{ result|json }}">
|
||||
<div class="font-medium">{{ result.name }}</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{% if result.address.city %}{{ result.address.city }}, {% endif %}
|
||||
{{ result.address.country }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="p-2 text-gray-500">No results found</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
279
templates/location/widget.html
Normal file
279
templates/location/widget.html
Normal file
@@ -0,0 +1,279 @@
|
||||
{% 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="location-widget" id="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 id="searchResults"
|
||||
style="position: absolute; top: 100%; left: 0; right: 0; z-index: 1000;"
|
||||
class="hidden w-full mt-1 overflow-auto bg-white border rounded-md shadow-lg max-h-60 dark:bg-gray-700 dark:border-gray-600">
|
||||
</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">
|
||||
Name
|
||||
</label>
|
||||
<input type="text"
|
||||
name="location_name"
|
||||
id="locationName"
|
||||
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.location_name.value|default:'' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Type
|
||||
</label>
|
||||
<input type="text"
|
||||
name="location_type"
|
||||
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="amusement_park"
|
||||
readonly>
|
||||
</div>
|
||||
<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('DOMContentLoaded', function() {
|
||||
let map = null;
|
||||
let marker = null;
|
||||
const searchInput = document.getElementById('locationSearch');
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
let searchTimeout;
|
||||
|
||||
// Initialize map
|
||||
function initMap() {
|
||||
map = L.map('locationMap').setView([0, 0], 2);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Initialize with existing coordinates if available
|
||||
const initialLat = document.getElementById('latitude').value;
|
||||
const initialLng = document.getElementById('longitude').value;
|
||||
if (initialLat && initialLng) {
|
||||
addMarker(parseFloat(initialLat), parseFloat(initialLng));
|
||||
}
|
||||
|
||||
// Handle map clicks
|
||||
map.on('click', async function(e) {
|
||||
const { lat, lng } = e.latlng;
|
||||
try {
|
||||
const response = await fetch(`/parks/search/reverse-geocode/?lat=${lat}&lon=${lng}`);
|
||||
const data = await response.json();
|
||||
updateLocation(lat, lng, data);
|
||||
} catch (error) {
|
||||
console.error('Reverse geocoding failed:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize map
|
||||
initMap();
|
||||
|
||||
// Handle location search
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = this.value.trim();
|
||||
|
||||
if (!query) {
|
||||
searchResults.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async function() {
|
||||
try {
|
||||
const response = await fetch(`/parks/search/location/?q=${encodeURIComponent(query)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.results && data.results.length > 0) {
|
||||
const resultsHtml = data.results.map((result, index) => `
|
||||
<div class="p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
data-result-index="${index}">
|
||||
<div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
${(result.address && result.address.city) ? result.address.city + ', ' : ''}${(result.address && result.address.country) || ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
searchResults.innerHTML = resultsHtml;
|
||||
searchResults.classList.remove('hidden');
|
||||
|
||||
// Store results data
|
||||
searchResults.dataset.results = JSON.stringify(data.results);
|
||||
|
||||
// Add click handlers
|
||||
searchResults.querySelectorAll('[data-result-index]').forEach(el => {
|
||||
el.addEventListener('click', function() {
|
||||
const results = JSON.parse(searchResults.dataset.results);
|
||||
const result = results[this.dataset.resultIndex];
|
||||
selectLocation(result);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
searchResults.innerHTML = '<div class="p-2 text-gray-500 dark:text-gray-400">No results found</div>';
|
||||
searchResults.classList.remove('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Hide search results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!searchResults.contains(e.target) && e.target !== searchInput) {
|
||||
searchResults.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function addMarker(lat, lng) {
|
||||
if (marker) {
|
||||
marker.remove();
|
||||
}
|
||||
marker = L.marker([lat, lng]).addTo(map);
|
||||
map.setView([lat, lng], 13);
|
||||
}
|
||||
|
||||
function updateLocation(lat, lng, data) {
|
||||
// Update coordinates
|
||||
document.getElementById('latitude').value = lat || '';
|
||||
document.getElementById('longitude').value = lng || '';
|
||||
|
||||
// Update marker
|
||||
if (lat && lng) {
|
||||
addMarker(lat, lng);
|
||||
}
|
||||
|
||||
// Update form fields
|
||||
const address = data.address || {};
|
||||
document.getElementById('locationName').value = data.name || data.display_name || '';
|
||||
document.getElementById('streetAddress').value =
|
||||
`${address.house_number || ''} ${address.road || address.street || ''}`.trim() || '';
|
||||
document.getElementById('city').value =
|
||||
address.city || address.town || address.village || '';
|
||||
document.getElementById('state').value =
|
||||
address.state || address.region || '';
|
||||
document.getElementById('country').value = address.country || '';
|
||||
document.getElementById('postalCode').value = address.postcode || '';
|
||||
}
|
||||
|
||||
function selectLocation(result) {
|
||||
if (!result) return;
|
||||
|
||||
const lat = parseFloat(result.lat);
|
||||
const lon = parseFloat(result.lon);
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) return;
|
||||
|
||||
// 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 : ''
|
||||
}
|
||||
};
|
||||
|
||||
updateLocation(lat, lon, address);
|
||||
searchResults.classList.add('hidden');
|
||||
searchInput.value = address.name;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,29 +1,44 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<!-- Park Header -->
|
||||
<div class="p-6 mb-6 bg-white border border-gray-200 rounded-lg shadow-lg dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="flex flex-col items-start justify-between md:flex-row md:items-center">
|
||||
<!-- Header -->
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">{{ park.name }}</h1>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
<i class="mr-2 fas fa-map-marker-alt"></i>
|
||||
<span>{{ park.get_formatted_location }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4 md:mt-0">
|
||||
{% if park.website %}
|
||||
<a href="{{ park.website }}" target="_blank" rel="noopener noreferrer"
|
||||
class="btn-secondary">
|
||||
<i class="mr-2 fas fa-external-link-alt"></i>Visit Website
|
||||
</a>
|
||||
{% if park.city or park.state or park.country %}
|
||||
<p class="mb-2 text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-1 fas fa-map-marker-alt"></i>
|
||||
{% if park.city %}{{ park.city }}{% endif %}
|
||||
{% if park.city and park.state %}, {% endif %}
|
||||
{% if park.state %}{{ park.state }}{% endif %}
|
||||
{% if park.country and park.state or park.city %}, {% endif %}
|
||||
{% if park.country %}{{ park.country }}{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if user.is_authenticated %}
|
||||
<a href="{% url 'parks:park_edit' slug=park.slug %}" class="btn-secondary">
|
||||
<div class="flex flex-wrap gap-2 mt-3">
|
||||
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
|
||||
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||
{% elif park.status == 'DEMOLISHED' %}status-demolished
|
||||
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
{% if park.average_rating %}
|
||||
<span class="flex items-center text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
|
||||
<span class="mr-1 text-yellow-500 dark:text-yellow-200">★</span>
|
||||
{{ park.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if user.is_authenticated %}
|
||||
<div class="flex gap-2">
|
||||
<a href="{% url 'parks:park_update' park.slug %}" class="btn-secondary">
|
||||
<i class="mr-2 fas fa-edit"></i>Edit
|
||||
</a>
|
||||
{% if perms.media.add_photo %}
|
||||
@@ -31,223 +46,143 @@
|
||||
<i class="mr-2 fas fa-camera"></i>Upload Photo
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-4">
|
||||
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
|
||||
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||
{% elif park.status == 'DEMOLISHED' %}status-demolished
|
||||
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
{% if park.average_rating %}
|
||||
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
|
||||
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
|
||||
{{ park.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photos -->
|
||||
{% if park.photos.exists %}
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">Photos</h2>
|
||||
<div class="p-6 mb-8 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
|
||||
{% include "media/partials/photo_display.html" with photos=park.photos.all content_type="parks.park" object_id=park.id %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Park Stats -->
|
||||
<div class="grid grid-cols-1 gap-6 mb-6 md:grid-cols-3">
|
||||
<a href="{% url 'parks:rides:ride_list' park.slug %}" class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ rides.count }}
|
||||
</div>
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-300">Total Attractions</div>
|
||||
</a>
|
||||
|
||||
<div class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{% with roller_coasters=rides|dictsortreversed:"category"|slice:":RC" %}
|
||||
{{ roller_coasters|length }}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-300">Roller Coasters</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ areas.count }}
|
||||
</div>
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-300">Areas</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Left Column - Description and Areas -->
|
||||
<!-- Left Column - Description and Details -->
|
||||
<div class="lg:col-span-2">
|
||||
{% if park.description %}
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">About</h2>
|
||||
<div class="text-gray-700 dark:text-gray-300">
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">About</h2>
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
{{ park.description|linebreaks }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Park Areas -->
|
||||
{% if areas %}
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">Areas</h2>
|
||||
<div class="grid gap-4">
|
||||
{% for area in areas %}
|
||||
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ area.name }}
|
||||
</h3>
|
||||
{% if area.description %}
|
||||
<p class="mb-2 text-gray-600 dark:text-gray-300">
|
||||
{{ area.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ area.rides.count }} attractions
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<!-- Location Map -->
|
||||
{% if park.latitude and park.longitude %}
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Location</h2>
|
||||
<div id="map" class="relative h-[400px] rounded-lg" style="z-index: 0;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Rides List -->
|
||||
<div class="p-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<!-- Rides and Attractions -->
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Attractions</h2>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Rides & Attractions</h2>
|
||||
<a href="{% url 'parks:rides:ride_list' park.slug %}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
View All <i class="ml-1 fas fa-arrow-right"></i>
|
||||
View All
|
||||
</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{% for ride in rides %}
|
||||
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||
<h3 class="mb-2 text-lg font-semibold">
|
||||
<a href="{% url 'parks:rides:ride_detail' park.slug ride.slug %}"
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
|
||||
{{ ride.name }}
|
||||
{% if park.rides.exists %}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
{% for ride in park.rides.all|slice:":6" %}
|
||||
<div class="p-4 transition-colors rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<a href="{% url 'parks:rides:ride_detail' park.slug ride.slug %}" class="block">
|
||||
<h3 class="mb-1 font-semibold text-gray-900 dark:text-white">{{ ride.name }}</h3>
|
||||
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50">
|
||||
{{ ride.get_category_display }}
|
||||
</span>
|
||||
{% if ride.average_rating %}
|
||||
<span class="flex items-center text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
|
||||
<span class="mr-1 text-yellow-500 dark:text-yellow-200">★</span>
|
||||
{{ ride.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-400/30 dark:text-blue-200 dark:ring-1 dark:ring-blue-400/30">
|
||||
{{ ride.get_category_display }}
|
||||
</span>
|
||||
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
|
||||
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
|
||||
{% else %}status-construction{% endif %}">
|
||||
{{ ride.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
{% if ride.coaster_stats %}
|
||||
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{% if ride.coaster_stats.height_ft %}
|
||||
<div>Height: {{ ride.coaster_stats.height_ft }}ft</div>
|
||||
{% endif %}
|
||||
{% if ride.coaster_stats.speed_mph %}
|
||||
<div>Speed: {{ ride.coaster_stats.speed_mph }}mph</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-span-2 py-8 text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">No attractions found.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 dark:text-gray-400">No rides or attractions listed yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column - Quick Facts and History -->
|
||||
<div>
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">Quick Facts</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<!-- Right Column - Quick Facts -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Quick Facts</h2>
|
||||
<dl class="space-y-4">
|
||||
{% if park.owner %}
|
||||
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
|
||||
<i class="w-5 text-blue-500 fas fa-building dark:text-blue-400"></i>
|
||||
<span class="ml-2">Operator</span>
|
||||
</dt>
|
||||
<div>
|
||||
<dt class="text-gray-500">Owner/Operator</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">
|
||||
<a href="{% url 'companies:company_detail' park.owner.slug %}"
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
{{ park.owner.name }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
|
||||
<i class="w-5 text-blue-500 fas fa-globe dark:text-blue-400"></i>
|
||||
<span class="ml-2">Location</span>
|
||||
</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">
|
||||
{{ park.get_formatted_location }}
|
||||
</dd>
|
||||
</div>
|
||||
{% if park.opening_date %}
|
||||
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
|
||||
<i class="w-5 text-blue-500 fas fa-calendar-alt dark:text-blue-400"></i>
|
||||
<span class="ml-2">Opening Date</span>
|
||||
</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">
|
||||
{{ park.opening_date }}
|
||||
</dd>
|
||||
<div>
|
||||
<dt class="text-gray-500">Opening Date</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ park.opening_date }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if park.closing_date %}
|
||||
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
|
||||
<i class="w-5 text-blue-500 fas fa-calendar-times dark:text-blue-400"></i>
|
||||
<span class="ml-2">Closing Date</span>
|
||||
</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">
|
||||
{{ park.closing_date }}
|
||||
</dd>
|
||||
<div>
|
||||
<dt class="text-gray-500">Closing Date</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ park.closing_date }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if park.operating_season %}
|
||||
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
|
||||
<i class="w-5 text-blue-500 fas fa-clock dark:text-blue-400"></i>
|
||||
<span class="ml-2">Operating Season</span>
|
||||
</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">
|
||||
{{ park.operating_season }}
|
||||
</dd>
|
||||
<div>
|
||||
<dt class="text-gray-500">Operating Season</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ park.operating_season }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if park.size_acres %}
|
||||
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
|
||||
<i class="w-5 text-blue-500 fas fa-ruler-combined dark:text-blue-400"></i>
|
||||
<span class="ml-2">Size</span>
|
||||
</dt>
|
||||
<div>
|
||||
<dt class="text-gray-500">Size</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ park.size_acres }} acres</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if park.total_rides %}
|
||||
<div>
|
||||
<dt class="text-gray-500">Total Rides</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ park.total_rides }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if park.total_roller_coasters %}
|
||||
<div>
|
||||
<dt class="text-gray-500">Roller Coasters</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ park.total_roller_coasters }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if park.website %}
|
||||
<div>
|
||||
<dt class="text-gray-500">Website</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">
|
||||
{{ park.size_acres }} acres
|
||||
<a href="{{ park.website }}"
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
{{ park.website }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- History Panel -->
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">History</h2>
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">History</h2>
|
||||
<div class="space-y-4">
|
||||
{% for record in history %}
|
||||
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||
@@ -280,7 +215,7 @@
|
||||
<div x-data="{ show: false }"
|
||||
@show-photo-upload.window="show = true"
|
||||
x-show="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black bg-opacity-50"
|
||||
@click.self="show = false">
|
||||
<div class="w-full max-w-2xl p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@@ -293,4 +228,29 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if park.latitude and park.longitude %}
|
||||
{% block extra_js %}
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const map = L.map('map').setView([{{ park.latitude }}, {{ park.longitude }}], 13);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map);
|
||||
|
||||
L.marker([{{ park.latitude }}, {{ park.longitude }}])
|
||||
.addTo(map)
|
||||
.bindPopup("{{ park.name }}");
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% if park.latitude and park.longitude %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,241 +1,321 @@
|
||||
{% extends 'base/base.html' %} {% load static %} {% block title %}{% if is_edit %}Edit {{ object.name }}{% else %}Add Park{% endif %} - ThrillWiki{% endblock %}
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<style>
|
||||
.photo-preview {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.photo-preview.uploading {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.photo-preview.error {
|
||||
border-color: red;
|
||||
}
|
||||
.photo-preview.success {
|
||||
border-color: green;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<!-- Park Form -->
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h1 class="mb-6 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{% if is_edit %}Edit {{ object.name }}{% else %}Add Park{% endif %}
|
||||
</h1>
|
||||
<div class="container px-4 py-8 mx-auto">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="mb-6 text-3xl font-bold">
|
||||
{% if is_edit %}Edit{% else %}Create{% endif %} Park
|
||||
</h1>
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="p-4 mb-6 text-red-700 bg-red-100 border border-red-400 rounded-lg dark:bg-red-900 dark:text-red-100 dark:border-red-700">
|
||||
<p class="font-medium">Please correct the following errors:</p>
|
||||
<ul class="ml-4 list-disc">
|
||||
{% for field in form %} {% for error in field.errors %}
|
||||
<li>{{ field.label }}: {{ error }}</li>
|
||||
{% endfor %} {% endfor %} {% for error in form.non_field_errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="parkForm()">
|
||||
{% csrf_token %}
|
||||
|
||||
<form method="post" class="space-y-6" id="park-form">
|
||||
{% csrf_token %}
|
||||
{# Basic Information #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Basic Information</h2>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="col-span-2">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Name
|
||||
</label>
|
||||
{{ form.name }}
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Description
|
||||
</label>
|
||||
{{ form.description }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Owner/Operator
|
||||
</label>
|
||||
{{ form.owner }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status
|
||||
</label>
|
||||
{{ form.status }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden fields -->
|
||||
{{ form.country }} {{ form.region }} {{ form.city }}
|
||||
{# Location #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Location</h2>
|
||||
{% include "parks/partials/location_widget.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Name field -->
|
||||
<div>
|
||||
<label for="{{ form.name.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Name *
|
||||
</label>
|
||||
<div>{{ form.name }}</div>
|
||||
{% if form.name.errors %}
|
||||
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.name.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Photos #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Photos</h2>
|
||||
|
||||
{# Existing Photos #}
|
||||
{% if park.photos.exists %}
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for photo in park.photos.all %}
|
||||
<div class="relative overflow-hidden rounded-lg aspect-w-16 aspect-h-9">
|
||||
<img src="{{ photo.image.url }}"
|
||||
alt="{{ photo.caption|default:park.name }}"
|
||||
class="object-cover w-full h-full">
|
||||
<div class="absolute top-0 right-0 p-2">
|
||||
<button type="button"
|
||||
class="p-2 text-white bg-red-600 rounded-full hover:bg-red-700"
|
||||
@click="removePhoto('{{ photo.id }}')">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location fields -->
|
||||
<div x-data="locationAutocomplete('country', false)" class="relative">
|
||||
<label
|
||||
for="{{ form.country_name.id_for_label }}"
|
||||
class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Country *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="id_country_name"
|
||||
name="country_name"
|
||||
x-model="query"
|
||||
@input.debounce.300ms="fetchSuggestions()"
|
||||
@focus="fetchSuggestions()"
|
||||
@click.away="suggestions = []"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Select country..."
|
||||
value="{{ form.country_name.value|default:'' }}"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<!-- Suggestions Dropdown -->
|
||||
<ul
|
||||
x-show="suggestions.length > 0"
|
||||
x-cloak
|
||||
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60"
|
||||
>
|
||||
<template x-for="suggestion in suggestions" :key="suggestion.id">
|
||||
<li
|
||||
@click="selectSuggestion(suggestion)"
|
||||
x-text="suggestion.name"
|
||||
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
></li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
{# Photo Upload #}
|
||||
<div class="space-y-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Add Photos
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
x-ref="fileInput"
|
||||
@change="handleFileSelect">
|
||||
<button type="button"
|
||||
class="w-full px-4 py-2 text-gray-700 border-2 border-dashed rounded-lg dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
@click="$refs.fileInput.click()">
|
||||
<span x-show="!previews.length">
|
||||
<i class="mr-2 fas fa-upload"></i>
|
||||
Click to upload photos
|
||||
</span>
|
||||
<span x-show="previews.length">
|
||||
<i class="mr-2 fas fa-plus"></i>
|
||||
Add more photos
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-data="locationAutocomplete('region', false)" class="relative">
|
||||
<label
|
||||
for="{{ form.region_name.id_for_label }}"
|
||||
class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Region/State
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="id_region_name"
|
||||
name="region_name"
|
||||
x-model="query"
|
||||
@input.debounce.300ms="fetchSuggestions()"
|
||||
@focus="fetchSuggestions()"
|
||||
@click.away="suggestions = []"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Select region/state..."
|
||||
value="{{ form.region_name.value|default:'' }}"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<!-- Suggestions Dropdown -->
|
||||
<ul
|
||||
x-show="suggestions.length > 0"
|
||||
x-cloak
|
||||
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60"
|
||||
>
|
||||
<template x-for="suggestion in suggestions" :key="suggestion.id">
|
||||
<li
|
||||
@click="selectSuggestion(suggestion)"
|
||||
x-text="suggestion.name"
|
||||
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
></li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
{# Photo Previews #}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<template x-for="(preview, index) in previews" :key="preview.id">
|
||||
<div class="relative overflow-hidden transition-all duration-300 rounded-lg aspect-w-16 aspect-h-9 photo-preview"
|
||||
:class="{
|
||||
'uploading': preview.uploading,
|
||||
'error': preview.error,
|
||||
'success': preview.uploaded
|
||||
}">
|
||||
<img :src="preview.url"
|
||||
class="object-cover w-full h-full">
|
||||
<div class="absolute top-0 right-0 p-2">
|
||||
<button type="button"
|
||||
class="p-2 text-white bg-red-600 rounded-full hover:bg-red-700"
|
||||
@click="removePreview(index)">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="preview.uploading"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<div class="w-16 h-16 border-4 border-blue-500 rounded-full animate-spin border-t-transparent"></div>
|
||||
</div>
|
||||
<div x-show="preview.error"
|
||||
class="absolute bottom-0 left-0 right-0 p-2 text-sm text-white bg-red-500">
|
||||
Upload failed
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="locationAutocomplete('city', false)" class="relative">
|
||||
<label
|
||||
for="{{ form.city_name.id_for_label }}"
|
||||
class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="id_city_name"
|
||||
name="city_name"
|
||||
x-model="query"
|
||||
@input.debounce.300ms="fetchSuggestions()"
|
||||
@focus="fetchSuggestions()"
|
||||
@click.away="suggestions = []"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Select city..."
|
||||
value="{{ form.city_name.value|default:'' }}"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<!-- Suggestions Dropdown -->
|
||||
<ul
|
||||
x-show="suggestions.length > 0"
|
||||
x-cloak
|
||||
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60"
|
||||
>
|
||||
<template x-for="suggestion in suggestions" :key="suggestion.id">
|
||||
<li
|
||||
@click="selectSuggestion(suggestion)"
|
||||
x-text="suggestion.name"
|
||||
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
></li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
{# Additional Details #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Additional Details</h2>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Opening Date
|
||||
</label>
|
||||
{{ form.opening_date }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Closing Date
|
||||
</label>
|
||||
{{ form.closing_date }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Operating Season
|
||||
</label>
|
||||
{{ form.operating_season }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Size (acres)
|
||||
</label>
|
||||
{{ form.size_acres }}
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Website
|
||||
</label>
|
||||
{{ form.website }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other fields -->
|
||||
{% for field in form %} {% if field.name not in 'name,country,region,city,country_name,region_name,city_name' %}
|
||||
<div>
|
||||
<label
|
||||
for="{{ field.id_for_label }}"
|
||||
class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ field.label }}{% if field.field.required %} *{% endif %}
|
||||
</label>
|
||||
<div>{{ field }}</div>
|
||||
{% if field.help_text %}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ field.help_text }}
|
||||
</p>
|
||||
{% endif %} {% if field.errors %}
|
||||
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||
{{ field.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %} {% endfor %} {% if not user.role == 'MODERATOR' and not user.role == 'ADMIN' and not user.role == 'SUPERUSER' %}
|
||||
<div
|
||||
class="p-4 mb-4 text-blue-700 bg-blue-100 border border-blue-400 rounded-lg dark:bg-blue-900 dark:text-blue-100 dark:border-blue-700"
|
||||
>
|
||||
<p>
|
||||
Your submission will be reviewed by a moderator before being
|
||||
published.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="reason"
|
||||
class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Reason for {% if is_edit %}Edit{% else %}Addition{% endif %} *
|
||||
</label>
|
||||
<textarea
|
||||
name="reason"
|
||||
id="reason"
|
||||
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
rows="3"
|
||||
required
|
||||
placeholder="Please explain why you're {% if is_edit %}editing{% else %}adding{% endif %} this park and provide any relevant details."
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="source"
|
||||
class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Source (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="source"
|
||||
id="source"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Link to official website, news article, or other source"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Submission Details #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Submission Details</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Reason for Changes
|
||||
</label>
|
||||
<textarea name="reason" rows="2"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Explain why you're making these changes"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Source
|
||||
</label>
|
||||
<input type="text" name="source"
|
||||
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"
|
||||
placeholder="Where did you get this information?">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-4">
|
||||
<a
|
||||
href="{% if is_edit %}{% url 'parks:park_detail' slug=object.slug %}{% else %}{% url 'parks:park_list' %}{% endif %}"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
{% if is_edit %}Save Changes{% else %}Submit{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{# Submit Button #}
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
class="px-6 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800"
|
||||
:disabled="uploading"
|
||||
x-text="uploading ? 'Uploading...' : '{% if is_edit %}Save Changes{% else %}Create Park{% endif %}'">
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Photos Section (only shown on edit) -->
|
||||
{% if is_edit %}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800" id="photos">
|
||||
{% include "media/partials/photo_manager.html" with photos=object.photos.all content_type="parks.park" object_id=object.id %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
function parkForm() {
|
||||
return {
|
||||
previews: [],
|
||||
uploading: false,
|
||||
|
||||
handleFileSelect(event) {
|
||||
const files = event.target.files;
|
||||
if (!files.length) return;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file.type.startsWith('image/')) continue;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.previews.push({
|
||||
id: Date.now() + i,
|
||||
file: file,
|
||||
url: e.target.result,
|
||||
uploading: false,
|
||||
uploaded: false,
|
||||
error: false
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// Reset file input
|
||||
event.target.value = '';
|
||||
},
|
||||
|
||||
removePreview(index) {
|
||||
this.previews.splice(index, 1);
|
||||
},
|
||||
|
||||
async uploadPhotos() {
|
||||
if (!this.previews.length) return true;
|
||||
|
||||
this.uploading = true;
|
||||
let allUploaded = true;
|
||||
|
||||
for (let preview of this.previews) {
|
||||
if (preview.uploaded || preview.error) continue;
|
||||
|
||||
preview.uploading = true;
|
||||
const formData = new FormData();
|
||||
formData.append('image', preview.file);
|
||||
formData.append('app_label', 'parks');
|
||||
formData.append('model', 'park');
|
||||
formData.append('object_id', '{{ park.id }}');
|
||||
|
||||
try {
|
||||
const response = await fetch('/photos/upload/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
|
||||
const result = await response.json();
|
||||
preview.uploading = false;
|
||||
preview.uploaded = true;
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
preview.uploading = false;
|
||||
preview.error = true;
|
||||
allUploaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.uploading = false;
|
||||
return allUploaded;
|
||||
},
|
||||
|
||||
removePhoto(photoId) {
|
||||
if (confirm('Are you sure you want to remove this photo?')) {
|
||||
fetch(`/photos/${photoId}/delete/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||
},
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -30,26 +30,26 @@
|
||||
</div>
|
||||
|
||||
<!-- Country Field -->
|
||||
<div x-data="locationAutocomplete('country', true)" class="relative">
|
||||
<div x-data="locationSearch('country')" class="relative">
|
||||
<label for="country" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Country</label>
|
||||
<input type="text"
|
||||
name="country"
|
||||
id="country"
|
||||
x-model="query"
|
||||
@input.debounce.300ms="fetchSuggestions()"
|
||||
@focus="fetchSuggestions()"
|
||||
@click.away="suggestions = []"
|
||||
@input.debounce.300ms="search()"
|
||||
@focus="search()"
|
||||
@click.away="results = []"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Select country..."
|
||||
value="{{ current_filters.country }}"
|
||||
autocomplete="off">
|
||||
<!-- Suggestions Dropdown -->
|
||||
<ul x-show="suggestions.length > 0"
|
||||
<!-- Results Dropdown -->
|
||||
<ul x-show="results.length > 0"
|
||||
x-cloak
|
||||
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60">
|
||||
<template x-for="suggestion in suggestions" :key="suggestion.id">
|
||||
<li @click="selectSuggestion(suggestion)"
|
||||
x-text="suggestion.name"
|
||||
<template x-for="result in results" :key="result.address.country">
|
||||
<li @click="select(result)"
|
||||
x-text="result.address.country"
|
||||
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
</li>
|
||||
</template>
|
||||
@@ -57,26 +57,26 @@
|
||||
</div>
|
||||
|
||||
<!-- Region Field -->
|
||||
<div x-data="locationAutocomplete('region', true)" class="relative">
|
||||
<div x-data="locationSearch('state')" class="relative">
|
||||
<label for="region" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">State/Region</label>
|
||||
<input type="text"
|
||||
name="region"
|
||||
id="region"
|
||||
x-model="query"
|
||||
@input.debounce.300ms="fetchSuggestions()"
|
||||
@focus="fetchSuggestions()"
|
||||
@click.away="suggestions = []"
|
||||
@input.debounce.300ms="search()"
|
||||
@focus="search()"
|
||||
@click.away="results = []"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Select state/region..."
|
||||
value="{{ current_filters.region }}"
|
||||
autocomplete="off">
|
||||
<!-- Suggestions Dropdown -->
|
||||
<ul x-show="suggestions.length > 0"
|
||||
<!-- Results Dropdown -->
|
||||
<ul x-show="results.length > 0"
|
||||
x-cloak
|
||||
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60">
|
||||
<template x-for="suggestion in suggestions" :key="suggestion.id">
|
||||
<li @click="selectSuggestion(suggestion)"
|
||||
x-text="suggestion.name"
|
||||
<template x-for="result in results" :key="result.address.state">
|
||||
<li @click="select(result)"
|
||||
x-text="result.address.state"
|
||||
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
</li>
|
||||
</template>
|
||||
@@ -84,26 +84,26 @@
|
||||
</div>
|
||||
|
||||
<!-- City Field -->
|
||||
<div x-data="locationAutocomplete('city', true)" class="relative">
|
||||
<div x-data="locationSearch('city')" class="relative">
|
||||
<label for="city" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">City</label>
|
||||
<input type="text"
|
||||
name="city"
|
||||
id="city"
|
||||
x-model="query"
|
||||
@input.debounce.300ms="fetchSuggestions()"
|
||||
@focus="fetchSuggestions()"
|
||||
@click.away="suggestions = []"
|
||||
@input.debounce.300ms="search()"
|
||||
@focus="search()"
|
||||
@click.away="results = []"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Select city..."
|
||||
value="{{ current_filters.city }}"
|
||||
autocomplete="off">
|
||||
<!-- Suggestions Dropdown -->
|
||||
<ul x-show="suggestions.length > 0"
|
||||
<!-- Results Dropdown -->
|
||||
<ul x-show="results.length > 0"
|
||||
x-cloak
|
||||
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60">
|
||||
<template x-for="suggestion in suggestions" :key="suggestion.id">
|
||||
<li @click="selectSuggestion(suggestion)"
|
||||
x-text="suggestion.name"
|
||||
<template x-for="result in results" :key="result.address.city">
|
||||
<li @click="select(result)"
|
||||
x-text="result.address.city"
|
||||
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
</li>
|
||||
</template>
|
||||
@@ -165,6 +165,45 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function locationSearch(type) {
|
||||
return {
|
||||
query: '',
|
||||
results: [],
|
||||
async search() {
|
||||
if (!this.query.trim()) {
|
||||
this.results = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/location/search/?q=${encodeURIComponent(this.query)}&type=${type}&filter_parks=true`);
|
||||
const data = await response.json();
|
||||
this.results = data.results;
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
this.results = [];
|
||||
}
|
||||
},
|
||||
select(result) {
|
||||
let value = '';
|
||||
switch (type) {
|
||||
case 'country':
|
||||
value = result.address.country;
|
||||
break;
|
||||
case 'state':
|
||||
value = result.address.state;
|
||||
break;
|
||||
case 'city':
|
||||
value = result.address.city;
|
||||
break;
|
||||
}
|
||||
this.query = value;
|
||||
this.results = [];
|
||||
document.getElementById('park-filters').dispatchEvent(new Event('change'));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toggleStatus(button, status) {
|
||||
const form = document.getElementById('park-filters');
|
||||
const existingInputs = form.querySelectorAll(`input[name="status"][value="${status}"]`);
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<option value="">---------</option>
|
||||
{% for city in cities %}
|
||||
<option value="{{ city.pk }}">{{ city.name }}</option>
|
||||
{% endfor %}
|
||||
@@ -1,15 +0,0 @@
|
||||
<div class="absolute z-10 w-full bg-white border rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
{% for country in countries %}
|
||||
<div class="px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
hx-post="{% url 'parks:select_country' %}"
|
||||
hx-target="#country-input"
|
||||
hx-swap="value"
|
||||
hx-vals='{"country": "{{ country.name }}"}'>
|
||||
{{ country.name }}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="px-4 py-2 text-gray-500 dark:text-gray-400">
|
||||
No countries found
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
350
templates/parks/partials/location_widget.html
Normal file
350
templates/parks/partials/location_widget.html
Normal file
@@ -0,0 +1,350 @@
|
||||
{% 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="location-widget" id="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 id="searchResults"
|
||||
style="position: absolute; top: 100%; left: 0; right: 0; z-index: 1000;"
|
||||
class="hidden w-full mt-1 overflow-auto bg-white border rounded-md shadow-lg max-h-60 dark:bg-gray-700 dark:border-gray-600">
|
||||
</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('DOMContentLoaded', function() {
|
||||
let map = null;
|
||||
let marker = null;
|
||||
const searchInput = document.getElementById('locationSearch');
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
let searchTimeout;
|
||||
|
||||
function normalizeCoordinate(value, maxDigits, decimalPlaces) {
|
||||
try {
|
||||
// Convert to string with exact decimal places
|
||||
const rounded = Number(value).toFixed(decimalPlaces);
|
||||
|
||||
// Convert to string without decimal point for digit counting
|
||||
const strValue = rounded.replace('.', '').replace('-', '');
|
||||
// Remove trailing zeros
|
||||
const strippedValue = strValue.replace(/0+$/, '');
|
||||
|
||||
// If total digits exceed maxDigits, round further
|
||||
if (strippedValue.length > maxDigits) {
|
||||
return Number(Number(value).toFixed(decimalPlaces - 1));
|
||||
}
|
||||
|
||||
// Return the string representation to preserve exact decimal places
|
||||
return rounded;
|
||||
} catch (error) {
|
||||
console.error('Coordinate normalization failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function validateCoordinates(lat, lng) {
|
||||
// Normalize coordinates
|
||||
const normalizedLat = normalizeCoordinate(lat, 9, 6);
|
||||
const normalizedLng = 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 };
|
||||
}
|
||||
|
||||
// Initialize map
|
||||
function initMap() {
|
||||
map = L.map('locationMap').setView([0, 0], 2);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Initialize with existing coordinates if available
|
||||
const initialLat = document.getElementById('latitude').value;
|
||||
const initialLng = document.getElementById('longitude').value;
|
||||
if (initialLat && initialLng) {
|
||||
try {
|
||||
const normalized = validateCoordinates(initialLat, initialLng);
|
||||
addMarker(normalized.lat, normalized.lng);
|
||||
} catch (error) {
|
||||
console.error('Invalid initial coordinates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle map clicks
|
||||
map.on('click', async function(e) {
|
||||
try {
|
||||
const normalized = validateCoordinates(e.latlng.lat, e.latlng.lng);
|
||||
const response = await fetch(`/parks/search/reverse-geocode/?lat=${normalized.lat}&lon=${normalized.lng}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Geocoding request failed');
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
updateLocation(normalized.lat, normalized.lng, data);
|
||||
} catch (error) {
|
||||
console.error('Location update failed:', error);
|
||||
alert(error.message || 'Failed to update location. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize map
|
||||
initMap();
|
||||
|
||||
// Handle location search
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = this.value.trim();
|
||||
|
||||
if (!query) {
|
||||
searchResults.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async function() {
|
||||
try {
|
||||
const response = await fetch(`/parks/search/location/?q=${encodeURIComponent(query)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Search request failed');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.results && data.results.length > 0) {
|
||||
const resultsHtml = data.results.map((result, index) => `
|
||||
<div class="p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
data-result-index="${index}">
|
||||
<div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
${(result.address && result.address.city) ? result.address.city + ', ' : ''}${(result.address && result.address.country) || ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
searchResults.innerHTML = resultsHtml;
|
||||
searchResults.classList.remove('hidden');
|
||||
|
||||
// Store results data
|
||||
searchResults.dataset.results = JSON.stringify(data.results);
|
||||
|
||||
// Add click handlers
|
||||
searchResults.querySelectorAll('[data-result-index]').forEach(el => {
|
||||
el.addEventListener('click', function() {
|
||||
const results = JSON.parse(searchResults.dataset.results);
|
||||
const result = results[this.dataset.resultIndex];
|
||||
selectLocation(result);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
searchResults.innerHTML = '<div class="p-2 text-gray-500 dark:text-gray-400">No results found</div>';
|
||||
searchResults.classList.remove('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
searchResults.innerHTML = '<div class="p-2 text-red-500 dark:text-red-400">Search failed. Please try again.</div>';
|
||||
searchResults.classList.remove('hidden');
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Hide search results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!searchResults.contains(e.target) && e.target !== searchInput) {
|
||||
searchResults.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function addMarker(lat, lng) {
|
||||
if (marker) {
|
||||
marker.remove();
|
||||
}
|
||||
marker = L.marker([lat, lng]).addTo(map);
|
||||
map.setView([lat, lng], 13);
|
||||
}
|
||||
|
||||
function updateLocation(lat, lng, data) {
|
||||
try {
|
||||
const normalized = validateCoordinates(lat, lng);
|
||||
|
||||
// Update coordinates
|
||||
document.getElementById('latitude').value = normalized.lat;
|
||||
document.getElementById('longitude').value = normalized.lng;
|
||||
|
||||
// Update marker
|
||||
addMarker(normalized.lat, normalized.lng);
|
||||
|
||||
// Update form fields with English names where available
|
||||
const address = data.address || {};
|
||||
document.getElementById('streetAddress').value =
|
||||
`${address.house_number || ''} ${address.road || address.street || ''}`.trim() || '';
|
||||
document.getElementById('city').value =
|
||||
address.city || address.town || address.village || '';
|
||||
document.getElementById('state').value =
|
||||
address.state || address.region || '';
|
||||
document.getElementById('country').value = address.country || '';
|
||||
document.getElementById('postalCode').value = address.postcode || '';
|
||||
} catch (error) {
|
||||
console.error('Location update failed:', error);
|
||||
alert(error.message || 'Failed to update location. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function 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 = 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 : ''
|
||||
}
|
||||
};
|
||||
|
||||
updateLocation(normalized.lat, normalized.lng, address);
|
||||
searchResults.classList.add('hidden');
|
||||
searchInput.value = address.name;
|
||||
} catch (error) {
|
||||
console.error('Location selection failed:', error);
|
||||
alert(error.message || 'Failed to select location. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// Add form submit handler
|
||||
const form = document.querySelector('form');
|
||||
form.addEventListener('submit', function(e) {
|
||||
const lat = document.getElementById('latitude').value;
|
||||
const lng = document.getElementById('longitude').value;
|
||||
|
||||
if (lat && lng) {
|
||||
try {
|
||||
validateCoordinates(lat, lng);
|
||||
} catch (error) {
|
||||
e.preventDefault();
|
||||
alert(error.message || 'Invalid coordinates. Please check the location.');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -16,10 +16,16 @@
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h2>
|
||||
<p class="mb-3 text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-1 fas fa-map-marker-alt"></i>
|
||||
{{ park.get_formatted_location }}
|
||||
</p>
|
||||
{% if park.city or park.state or park.country %}
|
||||
<p class="mb-3 text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-1 fas fa-map-marker-alt"></i>
|
||||
{% if park.city %}{{ park.city }}{% endif %}
|
||||
{% if park.city and park.state %}, {% endif %}
|
||||
{% if park.state %}{{ park.state }}{% endif %}
|
||||
{% if park.country and park.state or park.city %}, {% endif %}
|
||||
{% if park.country %}{{ park.country }}{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
|
||||
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||
@@ -35,18 +41,13 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<a href="{% url 'parks:rides:ride_list' park.slug %}"
|
||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
{{ park.rides.count }} attractions <i class="ml-1 fas fa-arrow-right"></i>
|
||||
</a>
|
||||
{% if park.owner %}
|
||||
<a href="{% url 'companies:company_detail' park.owner.slug %}"
|
||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
{% if park.owner %}
|
||||
<div class="mt-4 text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
<a href="{% url 'companies:company_detail' park.owner.slug %}">
|
||||
{{ park.owner.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<option value="">---------</option>
|
||||
{% for region in regions %}
|
||||
<option value="{{ region.pk }}">{{ region.name }}</option>
|
||||
{% endfor %}
|
||||
@@ -33,7 +33,7 @@
|
||||
<!-- Filters -->
|
||||
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<form class="grid grid-cols-1 gap-4 md:grid-cols-3"
|
||||
hx-get="{% if park %}{% url 'parks:rides:ride_list' park.slug %}{% else %}{% url 'parks:all_rides' %}{% endif %}"
|
||||
hx-get="{% if park %}{% url 'parks:rides:ride_list' park.slug %}{% else %}{% url 'rides:ride_list' %}{% endif %}"
|
||||
hx-trigger="change from:select, input from:input[type='text']"
|
||||
hx-target="#rides-grid"
|
||||
hx-push-url="true">
|
||||
|
||||
Reference in New Issue
Block a user