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:
pacnpal
2025-08-23 18:40:07 -04:00
parent b0e0678590
commit d504d41de2
762 changed files with 142636 additions and 0 deletions

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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>

View 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 %}

View 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">&laquo; 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 &raquo;</a>
{% endif %}
</div>
</div>
{% endif %}

View 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>

View 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, '&quot;')})"
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 %}