mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 12:11:13 -05:00
feat: complete monorepo structure with frontend and shared resources
- Add complete backend/ directory with full Django application - Add frontend/ directory with Vite + TypeScript setup ready for Next.js - Add comprehensive shared/ directory with: - Complete documentation and memory-bank archives - Media files and avatars (letters, park/ride images) - Deployment scripts and automation tools - Shared types and utilities - Add architecture/ directory with migration guides - Configure pnpm workspace for monorepo development - Update .gitignore to exclude .django_tailwind_cli/ build artifacts - Preserve all historical documentation in shared/docs/memory-bank/ - Set up proper structure for full-stack development with shared resources
This commit is contained in:
90
backend/templates/parks/area_detail.html
Normal file
90
backend/templates/parks/area_detail.html
Normal file
@@ -0,0 +1,90 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ area.name }} - {{ area.park.name }} - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="flex mb-4 text-sm" aria-label="Breadcrumb">
|
||||
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
||||
<li>
|
||||
<a href="{% url 'parks:park_detail' area.park.slug %}"
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
{{ area.park.name }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
<i class="mx-2 text-gray-400 fas fa-chevron-right"></i>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ area.name }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Area Header -->
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 lg:text-3xl dark:text-white">{{ area.name }}</h1>
|
||||
{% if user.is_authenticated %}
|
||||
<a href="#" class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-pencil-alt"></i>Edit
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if area.description %}
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
{{ area.description|linebreaks }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if area.opening_date or area.closing_date %}
|
||||
<div class="mt-4 space-y-2">
|
||||
{% if area.opening_date %}
|
||||
<div class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-calendar-plus"></i>
|
||||
<span>Opened: {{ area.opening_date }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if area.closing_date %}
|
||||
<div class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-calendar-minus"></i>
|
||||
<span>Closed: {{ area.closing_date }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Rides in this Area -->
|
||||
{% if area.rides.exists %}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Rides & Attractions</h2>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for ride in area.rides.all %}
|
||||
<div class="p-4 transition-colors rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<a href="{% url 'parks:rides:ride_detail' area.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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<p class="text-gray-500 dark:text-gray-400">No rides or attractions listed in this area yet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
285
backend/templates/parks/park_detail.html
Normal file
285
backend/templates/parks/park_detail.html
Normal file
@@ -0,0 +1,285 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
{% load park_tags %}
|
||||
|
||||
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% if park.location.exists %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('photoUploadModal', () => ({
|
||||
show: false,
|
||||
editingPhoto: { caption: '' }
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Action Buttons - Above header -->
|
||||
<div hx-get="{% url 'parks:park_actions' park.slug %}"
|
||||
hx-trigger="load, auth-changed from:body"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
|
||||
<!-- Park Header -->
|
||||
<div class="p-compact mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white lg:text-4xl">{{ park.name }}</h1>
|
||||
{% if park.formatted_location %}
|
||||
<div class="flex items-center justify-center mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-1 fas fa-map-marker-alt"></i>
|
||||
<p>{{ park.formatted_location }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
|
||||
<span class="status-badge text-sm font-medium py-1 px-3 {% 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 px-3 py-1 text-sm font-medium 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>
|
||||
</div>
|
||||
|
||||
<!-- Horizontal Stats Bar -->
|
||||
<div class="grid-stats mb-6">
|
||||
<!-- Operator - Priority Card (First Position) -->
|
||||
{% if park.operator %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats card-stats-priority">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Operator</dt>
|
||||
<dd class="mt-1">
|
||||
<span class="text-sm font-bold text-sky-900 dark:text-sky-400">
|
||||
{{ park.operator.name }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Property Owner (if different from operator) -->
|
||||
{% if park.property_owner and park.property_owner != park.operator %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Property Owner</dt>
|
||||
<dd class="mt-1">
|
||||
<a href="{% url 'property_owners:property_owner_detail' park.property_owner.slug %}"
|
||||
class="text-sm font-bold text-sky-900 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-300">
|
||||
{{ park.property_owner.name }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Total Rides -->
|
||||
<a href="{% url 'parks:rides:ride_list' park.slug %}"
|
||||
class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats transition-transform hover:scale-[1.02]">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Total Rides</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-sky-900 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-300">{{ park.ride_count|default:"N/A" }}</dd>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Roller Coasters -->
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Roller Coasters</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-sky-900 dark:text-sky-400">{{ park.coaster_count|default:"N/A" }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Status</dt>
|
||||
<dd class="mt-1 text-sm font-bold text-sky-900 dark:text-sky-400">{{ park.get_status_display }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opened Date -->
|
||||
{% if park.opening_date %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Opened</dt>
|
||||
<dd class="mt-1 text-sm font-bold text-sky-900 dark:text-sky-400">{{ park.opening_date }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Website -->
|
||||
{% if park.website %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Website</dt>
|
||||
<dd class="mt-1">
|
||||
<a href="{{ park.website }}"
|
||||
class="inline-flex items-center text-sm font-bold text-sky-900 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-300"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
Visit
|
||||
<i class="ml-1 text-xs fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Rest of the content remains unchanged -->
|
||||
{% if park.photos.exists %}
|
||||
<div class="p-optimized 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 %}
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Left Column - Description and Rides -->
|
||||
<div class="lg:col-span-2">
|
||||
{% if park.description %}
|
||||
<div class="p-optimized 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 %}
|
||||
|
||||
<!-- Rides and Attractions -->
|
||||
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
{% 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>
|
||||
</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 - Map and Additional Info -->
|
||||
<div class="lg:col-span-1">
|
||||
<!-- Location Map -->
|
||||
{% if park.location.exists %}
|
||||
<div class="p-optimized 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="park-map" class="relative rounded-lg" style="z-index: 0;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- History Panel -->
|
||||
<div class="p-optimized 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">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ record.history_date|date:"M d, Y H:i" }}
|
||||
{% if record.history_user %}
|
||||
by {{ record.history_user.username }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
{% for field, changes in record.diff_against_previous.items %}
|
||||
{% if field != "updated_at" %}
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">{{ field|title }}:</span>
|
||||
<span class="text-red-600 dark:text-red-400">{{ changes.old }}</span>
|
||||
<span class="mx-1">→</span>
|
||||
<span class="text-green-600 dark:text-green-400">{{ changes.new }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-gray-500">No history available.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Upload Modal -->
|
||||
{% if perms.media.add_photo %}
|
||||
<div x-cloak
|
||||
x-data="{
|
||||
show: false,
|
||||
editingPhoto: null,
|
||||
init() {
|
||||
this.editingPhoto = { caption: '' };
|
||||
}
|
||||
}"
|
||||
@show-photo-upload.window="show = true; init()"
|
||||
x-show="show"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/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">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Upload Photos</h3>
|
||||
<button @click="show = false" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
<i class="text-xl fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% include "media/partials/photo_upload.html" with content_type="parks.park" object_id=park.id %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- Photo Gallery Script -->
|
||||
<script src="{% static 'js/photo-gallery.js' %}"></script>
|
||||
|
||||
<!-- Map Script (if location exists) -->
|
||||
{% if park.location.exists %}
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="{% static 'js/park-map.js' %}"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
{% with location=park.location.first %}
|
||||
initParkMap({{ location.latitude }}, {{ location.longitude }}, "{{ park.name }}");
|
||||
{% endwith %}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
321
backend/templates/parks/park_form.html
Normal file
321
backend/templates/parks/park_form.html
Normal file
@@ -0,0 +1,321 @@
|
||||
{% 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 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>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="parkForm()">
|
||||
{% 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>
|
||||
|
||||
{# 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>
|
||||
|
||||
{# 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 %}
|
||||
|
||||
{# 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>
|
||||
|
||||
{# 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/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>
|
||||
|
||||
{# 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>
|
||||
|
||||
{# 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>
|
||||
|
||||
{# 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>
|
||||
</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 %}
|
||||
5
backend/templates/parks/partials/add_park_button.html
Normal file
5
backend/templates/parks/partials/add_park_button.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% if user.is_authenticated %}
|
||||
<a href="{% url 'parks:park_create' %}" class="btn-primary">
|
||||
<i class="mr-2 fas fa-plus"></i>Add Park
|
||||
</a>
|
||||
{% endif %}
|
||||
356
backend/templates/parks/partials/location_widget.html
Normal file
356
backend/templates/parks/partials/location_widget.html
Normal file
@@ -0,0 +1,356 @@
|
||||
{% 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-3 with exact decimal places
|
||||
const rounded = Number(value).toFixed(decimalPlaces);
|
||||
|
||||
// Convert to string-3 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-3 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.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>
|
||||
`).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.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) || ''
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
37
backend/templates/parks/partials/park_actions.html
Normal file
37
backend/templates/parks/partials/park_actions.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% load park_tags %}
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<div class="flex justify-end gap-2 mb-2">
|
||||
<a href="{% url 'parks:park_update' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-pencil-alt"></i>Edit
|
||||
</a>
|
||||
{% include "rides/partials/add_ride_modal.html" %}
|
||||
{% if perms.media.add_photo %}
|
||||
<button class="transition-transform btn-secondary hover:scale-105"
|
||||
@click="$dispatch('show-photo-upload')">
|
||||
<i class="mr-1 fas fa-camera"></i>Upload Photo
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<!-- Add/Edit Review Button -->
|
||||
{% if not park.reviews.exists %}
|
||||
<a href="{% url 'reviews:add_review' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-star"></i>Add Review
|
||||
</a>
|
||||
{% else %}
|
||||
{% if user|has_reviewed_park:park %}
|
||||
<a href="{% url 'reviews:edit_review' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-star"></i>Edit Review
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'reviews:add_review' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-star"></i>Add Review
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
76
backend/templates/parks/partials/park_list.html
Normal file
76
backend/templates/parks/partials/park_list.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<!-- Parks Grid -->
|
||||
<div class="grid-adaptive">
|
||||
{% for park in parks %}
|
||||
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||
{% if park.photos.exists %}
|
||||
<div class="aspect-w-16 aspect-h-9">
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="object-cover w-full">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="p-4">
|
||||
<h2 class="mb-2 text-xl font-bold">
|
||||
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h2>
|
||||
{% 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>
|
||||
{% spaceless %}
|
||||
{% 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>
|
||||
{% endspaceless %}
|
||||
</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
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if park.operator %}
|
||||
<div class="mt-4 text-sm text-blue-600 dark:text-blue-400">
|
||||
{{ park.operator.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-span-3 py-8 text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">No parks found matching your criteria.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="inline-flex rounded-md shadow-xs">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page=1{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">« First</a>
|
||||
<a href="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
|
||||
<a href="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
27
backend/templates/parks/partials/park_search_results.html
Normal file
27
backend/templates/parks/partials/park_search_results.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<div class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
|
||||
{% if parks %}
|
||||
{% for park in parks %}
|
||||
<button type="button"
|
||||
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
|
||||
onclick="selectPark('{{ park.id }}', '{{ park.name|escapejs }}')">
|
||||
{{ park.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
|
||||
{% if search_term %}
|
||||
No parks found
|
||||
{% else %}
|
||||
Start typing to search...
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectPark(id, name) {
|
||||
document.getElementById('id_park').value = id;
|
||||
document.getElementById('id_park_search').value = name;
|
||||
document.getElementById('park-search-results').innerHTML = '';
|
||||
}
|
||||
</script>
|
||||
788
backend/templates/parks/roadtrip_planner.html
Normal file
788
backend/templates/parks/roadtrip_planner.html
Normal file
@@ -0,0 +1,788 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Road Trip Planner - ThrillWiki{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<!-- Leaflet Routing Machine CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.css" />
|
||||
|
||||
<style>
|
||||
.map-container {
|
||||
height: 70vh;
|
||||
min-height: 500px;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.park-selection-card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm hover:shadow-md transition-all cursor-pointer border-2 border-transparent;
|
||||
}
|
||||
|
||||
.park-selection-card:hover {
|
||||
@apply border-blue-200 dark:border-blue-700;
|
||||
}
|
||||
|
||||
.park-selection-card.selected {
|
||||
@apply border-blue-500 bg-blue-50 dark:bg-blue-900 dark:bg-opacity-30;
|
||||
}
|
||||
|
||||
.park-card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm;
|
||||
}
|
||||
|
||||
.trip-summary-card {
|
||||
@apply bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900 dark:to-indigo-900 rounded-lg p-4 shadow-sm;
|
||||
}
|
||||
|
||||
.waypoint-marker {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.waypoint-marker-inner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.waypoint-start .waypoint-marker-inner {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.waypoint-end .waypoint-marker-inner {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.waypoint-stop .waypoint-marker-inner {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.route-line {
|
||||
color: #3b82f6;
|
||||
weight: 4;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dark .route-line {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.trip-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.trip-stat {
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
.trip-stat-value {
|
||||
@apply text-2xl font-bold text-blue-600 dark:text-blue-400;
|
||||
}
|
||||
|
||||
.trip-stat-label {
|
||||
@apply text-sm text-gray-600 dark:text-gray-400 mt-1;
|
||||
}
|
||||
|
||||
.draggable-item {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.draggable-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.drag-over {
|
||||
@apply border-dashed border-2 border-blue-400 bg-blue-50 dark:bg-blue-900 dark:bg-opacity-30;
|
||||
}
|
||||
|
||||
.park-search-result {
|
||||
@apply p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.park-search-result:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.park-search-result:hover {
|
||||
@apply bg-gray-50 dark:bg-gray-700;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Road Trip Planner</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Plan the perfect theme park adventure across multiple destinations
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'maps:universal_map' %}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="mr-2 fas fa-globe"></i>View Map
|
||||
</a>
|
||||
<a href="{% url 'parks:park_list' %}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="mr-2 fas fa-list"></i>Browse Parks
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Left Panel - Trip Planning -->
|
||||
<div class="lg:col-span-1 space-y-6">
|
||||
<!-- Park Search -->
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Add Parks to Trip</h3>
|
||||
|
||||
<div class="relative">
|
||||
<input type="text" id="park-search"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Search parks by name or location..."
|
||||
hx-get="{% url 'parks:htmx_search_parks' %}"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#park-search-results"
|
||||
hx-indicator="#search-loading">
|
||||
|
||||
<div id="search-loading" class="htmx-indicator absolute right-3 top-3">
|
||||
<div class="w-4 h-4 border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 hidden">
|
||||
<!-- Search results will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trip Itinerary -->
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
|
||||
<button id="clear-trip"
|
||||
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
onclick="tripPlanner.clearTrip()">
|
||||
<i class="mr-1 fas fa-trash"></i>Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="trip-parks" class="space-y-2 min-h-20">
|
||||
<div id="empty-trip" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-route text-3xl mb-3"></i>
|
||||
<p>Add parks to start planning your trip</p>
|
||||
<p class="text-sm mt-1">Search above or click parks on the map</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<button id="optimize-route"
|
||||
class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="tripPlanner.optimizeRoute()" disabled>
|
||||
<i class="mr-2 fas fa-route"></i>Optimize Route
|
||||
</button>
|
||||
<button id="calculate-route"
|
||||
class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="tripPlanner.calculateRoute()" disabled>
|
||||
<i class="mr-2 fas fa-map"></i>Calculate Route
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trip Summary -->
|
||||
<div id="trip-summary" class="trip-summary-card hidden">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Trip Summary</h3>
|
||||
|
||||
<div class="trip-stats">
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-distance">-</div>
|
||||
<div class="trip-stat-label">Total Miles</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-time">-</div>
|
||||
<div class="trip-stat-label">Drive Time</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-parks">-</div>
|
||||
<div class="trip-stat-label">Parks</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-rides">-</div>
|
||||
<div class="trip-stat-label">Total Rides</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button id="save-trip"
|
||||
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
|
||||
onclick="tripPlanner.saveTrip()">
|
||||
<i class="mr-2 fas fa-save"></i>Save Trip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel - Map -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Route Map</h3>
|
||||
<div class="flex gap-2">
|
||||
<button id="fit-route"
|
||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
onclick="tripPlanner.fitRoute()">
|
||||
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
|
||||
</button>
|
||||
<button id="toggle-parks"
|
||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
onclick="tripPlanner.toggleAllParks()">
|
||||
<i class="mr-1 fas fa-eye"></i>Show All Parks
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="map-container" class="map-container"></div>
|
||||
|
||||
<!-- Map Loading Indicator -->
|
||||
<div id="map-loading" class="htmx-indicator absolute inset-0 flex items-center justify-center 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>
|
||||
</div>
|
||||
|
||||
<!-- Saved Trips Section -->
|
||||
<div class="mt-8">
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">My Saved Trips</h3>
|
||||
<button class="px-3 py-1 text-sm text-blue-600 hover:text-blue-700"
|
||||
hx-get="{% url 'parks:htmx_saved_trips' %}"
|
||||
hx-target="#saved-trips"
|
||||
hx-trigger="click">
|
||||
<i class="mr-1 fas fa-sync"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="saved-trips"
|
||||
hx-get="{% url 'parks:htmx_saved_trips' %}"
|
||||
hx-trigger="load"
|
||||
hx-indicator="#trips-loading">
|
||||
<!-- Saved trips will be loaded here -->
|
||||
</div>
|
||||
|
||||
<div id="trips-loading" class="htmx-indicator text-center py-4">
|
||||
<div class="w-6 h-6 mx-auto border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2">Loading saved trips...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- Leaflet JS -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<!-- Leaflet Routing Machine JS -->
|
||||
<script src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.js"></script>
|
||||
<!-- Sortable JS for drag & drop -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Road Trip Planner class
|
||||
class TripPlanner {
|
||||
constructor() {
|
||||
this.map = null;
|
||||
this.tripParks = [];
|
||||
this.allParks = [];
|
||||
this.parkMarkers = {};
|
||||
this.routeControl = null;
|
||||
this.showingAllParks = false;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.initMap();
|
||||
this.loadAllParks();
|
||||
this.initDragDrop();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
initMap() {
|
||||
// Initialize the map
|
||||
this.map = L.map('map-container', {
|
||||
center: [39.8283, -98.5795],
|
||||
zoom: 4,
|
||||
zoomControl: false
|
||||
});
|
||||
|
||||
// Add custom zoom control
|
||||
L.control.zoom({
|
||||
position: 'bottomright'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Add tile layers with dark mode support
|
||||
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
});
|
||||
|
||||
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors, © CARTO'
|
||||
});
|
||||
|
||||
// Set initial tiles based on theme
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
darkTiles.addTo(this.map);
|
||||
} else {
|
||||
lightTiles.addTo(this.map);
|
||||
}
|
||||
|
||||
// Listen for theme changes
|
||||
this.observeThemeChanges(lightTiles, darkTiles);
|
||||
}
|
||||
|
||||
observeThemeChanges(lightTiles, darkTiles) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
this.map.removeLayer(lightTiles);
|
||||
this.map.addLayer(darkTiles);
|
||||
} else {
|
||||
this.map.removeLayer(darkTiles);
|
||||
this.map.addLayer(lightTiles);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
}
|
||||
|
||||
async loadAllParks() {
|
||||
try {
|
||||
const response = await fetch('{{ map_api_urls.locations }}?types=park&limit=1000');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.data.locations) {
|
||||
this.allParks = data.data.locations;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load parks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
initDragDrop() {
|
||||
// Make trip parks sortable
|
||||
new Sortable(document.getElementById('trip-parks'), {
|
||||
animation: 150,
|
||||
ghostClass: 'drag-over',
|
||||
onEnd: (evt) => {
|
||||
this.reorderTripParks(evt.oldIndex, evt.newIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Handle park search results
|
||||
document.addEventListener('htmx:afterRequest', (event) => {
|
||||
if (event.target.id === 'park-search-results') {
|
||||
this.handleSearchResults();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleSearchResults() {
|
||||
const results = document.getElementById('park-search-results');
|
||||
if (results.children.length > 0) {
|
||||
results.classList.remove('hidden');
|
||||
} else {
|
||||
results.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
addParkToTrip(parkData) {
|
||||
// Check if park already in trip
|
||||
if (this.tripParks.find(p => p.id === parkData.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tripParks.push(parkData);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
// Hide search results
|
||||
document.getElementById('park-search-results').classList.add('hidden');
|
||||
document.getElementById('park-search').value = '';
|
||||
}
|
||||
|
||||
removeParkFromTrip(parkId) {
|
||||
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateTripDisplay() {
|
||||
const container = document.getElementById('trip-parks');
|
||||
const emptyState = document.getElementById('empty-trip');
|
||||
|
||||
if (this.tripParks.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
// Clear existing parks (except empty state)
|
||||
Array.from(container.children).forEach(child => {
|
||||
if (child.id !== 'empty-trip') {
|
||||
child.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Add trip parks
|
||||
this.tripParks.forEach((park, index) => {
|
||||
const parkElement = this.createTripParkElement(park, index);
|
||||
container.appendChild(parkElement);
|
||||
});
|
||||
}
|
||||
|
||||
createTripParkElement(park, index) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'park-card draggable-item';
|
||||
div.innerHTML = `
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold">
|
||||
${index + 1}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
${park.name}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||
${park.formatted_location || 'Location not specified'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
|
||||
class="text-red-500 hover:text-red-700 p-1">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<i class="fas fa-grip-vertical text-gray-400 cursor-grab"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return div;
|
||||
}
|
||||
|
||||
updateTripMarkers() {
|
||||
// Clear existing trip markers
|
||||
Object.values(this.parkMarkers).forEach(marker => {
|
||||
this.map.removeLayer(marker);
|
||||
});
|
||||
this.parkMarkers = {};
|
||||
|
||||
// Add markers for trip parks
|
||||
this.tripParks.forEach((park, index) => {
|
||||
const marker = this.createTripMarker(park, index);
|
||||
this.parkMarkers[park.id] = marker;
|
||||
marker.addTo(this.map);
|
||||
});
|
||||
|
||||
// Fit map to show all trip parks
|
||||
if (this.tripParks.length > 0) {
|
||||
this.fitRoute();
|
||||
}
|
||||
}
|
||||
|
||||
createTripMarker(park, index) {
|
||||
let markerClass = 'waypoint-stop';
|
||||
if (index === 0) markerClass = 'waypoint-start';
|
||||
if (index === this.tripParks.length - 1 && this.tripParks.length > 1) markerClass = 'waypoint-end';
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: `waypoint-marker ${markerClass}`,
|
||||
html: `<div class="waypoint-marker-inner">${index + 1}</div>`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15]
|
||||
});
|
||||
|
||||
const marker = L.marker([park.latitude, park.longitude], { icon });
|
||||
|
||||
const popupContent = `
|
||||
<div class="text-center">
|
||||
<h3 class="font-semibold mb-2">${park.name}</h3>
|
||||
<div class="text-sm text-gray-600 mb-2">Stop ${index + 1}</div>
|
||||
${park.ride_count ? `<div class="text-sm text-gray-600 mb-2">${park.ride_count} rides</div>` : ''}
|
||||
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
|
||||
class="px-3 py-1 text-sm text-red-600 border border-red-600 rounded hover:bg-red-50">
|
||||
Remove from Trip
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
return marker;
|
||||
}
|
||||
|
||||
reorderTripParks(oldIndex, newIndex) {
|
||||
const park = this.tripParks.splice(oldIndex, 1)[0];
|
||||
this.tripParks.splice(newIndex, 0, park);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
|
||||
// Clear route to force recalculation
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
async optimizeRoute() {
|
||||
if (this.tripParks.length < 2) return;
|
||||
|
||||
try {
|
||||
const parkIds = this.tripParks.map(p => p.id);
|
||||
const response = await fetch('{% url "parks:htmx_optimize_route" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({ park_ids: parkIds })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.optimized_order) {
|
||||
// Reorder parks based on optimization
|
||||
const optimizedParks = data.optimized_order.map(id =>
|
||||
this.tripParks.find(p => p.id === id)
|
||||
).filter(Boolean);
|
||||
|
||||
this.tripParks = optimizedParks;
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Route optimization failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async calculateRoute() {
|
||||
if (this.tripParks.length < 2) return;
|
||||
|
||||
// Remove existing route
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
}
|
||||
|
||||
const waypoints = this.tripParks.map(park =>
|
||||
L.latLng(park.latitude, park.longitude)
|
||||
);
|
||||
|
||||
this.routeControl = L.Routing.control({
|
||||
waypoints: waypoints,
|
||||
routeWhileDragging: false,
|
||||
addWaypoints: false,
|
||||
createMarker: () => null, // Don't create default markers
|
||||
lineOptions: {
|
||||
styles: [{ color: '#3b82f6', weight: 4, opacity: 0.7 }]
|
||||
}
|
||||
}).addTo(this.map);
|
||||
|
||||
this.routeControl.on('routesfound', (e) => {
|
||||
const route = e.routes[0];
|
||||
this.updateTripSummary(route);
|
||||
});
|
||||
}
|
||||
|
||||
updateTripSummary(route) {
|
||||
if (!route) return;
|
||||
|
||||
const totalDistance = (route.summary.totalDistance / 1609.34).toFixed(1); // Convert to miles
|
||||
const totalTime = this.formatDuration(route.summary.totalTime);
|
||||
const totalRides = this.tripParks.reduce((sum, park) => sum + (park.ride_count || 0), 0);
|
||||
|
||||
document.getElementById('total-distance').textContent = totalDistance;
|
||||
document.getElementById('total-time').textContent = totalTime;
|
||||
document.getElementById('total-parks').textContent = this.tripParks.length;
|
||||
document.getElementById('total-rides').textContent = totalRides;
|
||||
|
||||
document.getElementById('trip-summary').classList.remove('hidden');
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
fitRoute() {
|
||||
if (this.tripParks.length === 0) return;
|
||||
|
||||
const group = new L.featureGroup(Object.values(this.parkMarkers));
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
|
||||
toggleAllParks() {
|
||||
// Implementation for showing/hiding all parks on the map
|
||||
const button = document.getElementById('toggle-parks');
|
||||
const icon = button.querySelector('i');
|
||||
|
||||
if (this.showingAllParks) {
|
||||
// Hide all parks
|
||||
this.showingAllParks = false;
|
||||
icon.className = 'mr-1 fas fa-eye';
|
||||
button.innerHTML = icon.outerHTML + 'Show All Parks';
|
||||
} else {
|
||||
// Show all parks
|
||||
this.showingAllParks = true;
|
||||
icon.className = 'mr-1 fas fa-eye-slash';
|
||||
button.innerHTML = icon.outerHTML + 'Hide All Parks';
|
||||
this.displayAllParks();
|
||||
}
|
||||
}
|
||||
|
||||
displayAllParks() {
|
||||
// Add markers for all parks (implementation depends on requirements)
|
||||
this.allParks.forEach(park => {
|
||||
if (!this.parkMarkers[park.id]) {
|
||||
const marker = L.marker([park.latitude, park.longitude], {
|
||||
icon: L.divIcon({
|
||||
className: 'location-marker location-marker-park',
|
||||
html: '<div class="location-marker-inner">🎢</div>',
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10]
|
||||
})
|
||||
});
|
||||
|
||||
marker.bindPopup(`
|
||||
<div class="text-center">
|
||||
<h3 class="font-semibold mb-2">${park.name}</h3>
|
||||
<button onclick="tripPlanner.addParkToTrip(${JSON.stringify(park).replace(/"/g, '"')})"
|
||||
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
|
||||
Add to Trip
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
marker.addTo(this.map);
|
||||
this.parkMarkers[`all_${park.id}`] = marker;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateButtons() {
|
||||
const optimizeBtn = document.getElementById('optimize-route');
|
||||
const calculateBtn = document.getElementById('calculate-route');
|
||||
|
||||
const hasEnoughParks = this.tripParks.length >= 2;
|
||||
|
||||
optimizeBtn.disabled = !hasEnoughParks;
|
||||
calculateBtn.disabled = !hasEnoughParks;
|
||||
}
|
||||
|
||||
clearTrip() {
|
||||
this.tripParks = [];
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
|
||||
document.getElementById('trip-summary').classList.add('hidden');
|
||||
}
|
||||
|
||||
async saveTrip() {
|
||||
if (this.tripParks.length === 0) return;
|
||||
|
||||
const tripName = prompt('Enter a name for this trip:');
|
||||
if (!tripName) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('{% url "parks:htmx_save_trip" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: tripName,
|
||||
park_ids: this.tripParks.map(p => p.id)
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
alert('Trip saved successfully!');
|
||||
// Refresh saved trips
|
||||
htmx.trigger('#saved-trips', 'refresh');
|
||||
} else {
|
||||
alert('Failed to save trip: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save trip failed:', error);
|
||||
alert('Failed to save trip');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global function for adding parks from search results
|
||||
window.addParkToTrip = function(parkData) {
|
||||
window.tripPlanner.addParkToTrip(parkData);
|
||||
};
|
||||
|
||||
// Initialize trip planner when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.tripPlanner = new TripPlanner();
|
||||
|
||||
// Hide search results when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#park-search') && !e.target.closest('#park-search-results')) {
|
||||
document.getElementById('park-search-results').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user