remove backend

This commit is contained in:
pacnpal
2025-09-21 20:19:12 -04:00
parent 9e724bd795
commit f3c59ad6ff
557 changed files with 1739 additions and 4836 deletions

View File

@@ -0,0 +1,139 @@
{% extends "base/base.html" %}
{% load static %}
{% load ride_tags %}
{% block title %}
{% if park %}
{{ category }} at {{ park.name }} - ThrillWiki
{% else %}
{{ category }} - ThrillWiki
{% endif %}
{% endblock %}
{% block content %}
<div class="container px-4 py-8 mx-auto">
<div class="mb-8">
{% if park %}
<h1 class="mb-2 text-3xl font-bold">{{ category }} at {{ park.name }}</h1>
<a href="{% url 'parks:park_detail' park.slug %}" class="text-blue-600 hover:text-blue-800">← Back to {{ park.name }}</a>
{% else %}
<h1 class="mb-2 text-3xl font-bold">{{ category }}</h1>
<a href="{% url 'rides:global_ride_list' %}" class="text-blue-600 hover:text-blue-800">← Back to All Rides</a>
{% endif %}
</div>
{% if rides %}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for ride in rides %}
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<div class="aspect-w-16 aspect-h-9">
{% if ride.photos.exists %}
<img src="{{ ride.photos.first.image.url }}"
alt="{{ ride.name }}"
class="object-cover w-full">
{% else %}
<img src="{% get_ride_placeholder_image ride.category %}"
alt="{{ ride.name }}"
class="object-cover w-full">
{% endif %}
</div>
<div class="p-4">
<h2 class="mb-2 text-xl font-bold">
{% if park %}
<a href="{% url 'parks:rides:ride_detail' park.slug ride.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ ride.name }}
</a>
{% else %}
<a href="{% url 'rides:ride_detail' ride.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ ride.name }}
</a>
{% endif %}
</h2>
{% if not park %}
<p class="mb-3 text-gray-600 dark:text-gray-400">
at <a href="{% url 'parks:park_detail' ride.park.slug %}"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
{{ ride.park.name }}
</a>
</p>
{% endif %}
{% if ride.ride_model %}
<p class="mb-2 text-gray-600 dark:text-gray-400">
Model: {{ ride.ride_model.name }}
{% if ride.ride_model.manufacturer %}
by {{ ride.ride_model.manufacturer.name }}
{% endif %}
</p>
{% endif %}
<div class="flex flex-wrap gap-2">
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif ride.status == 'DEMOLISHED' %}status-demolished
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ ride.get_status_display }}
</span>
{% if ride.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>
{{ ride.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</div>
{% if ride.coaster_stats %}
<div class="grid grid-cols-2 gap-2 mt-4">
{% if ride.coaster_stats.height_ft %}
<div class="text-sm text-gray-600 dark:text-gray-400">
Height: {{ ride.coaster_stats.height_ft }}ft
</div>
{% endif %}
{% if ride.coaster_stats.speed_mph %}
<div class="text-sm text-gray-600 dark:text-gray-400">
Speed: {{ ride.coaster_stats.speed_mph }}mph
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="p-8 text-center rounded-lg bg-gray-50 dark:bg-gray-800">
{% if park %}
<p class="text-gray-600 dark:text-gray-400">No {{ category|lower }} found at this park.</p>
{% else %}
<p class="text-gray-600 dark:text-gray-400">No {{ category|lower }} found.</p>
{% endif %}
</div>
{% endif %}
<!-- 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 %}
</div>
{% endblock %}

View File

@@ -0,0 +1,47 @@
<!-- Add Ride Modal -->
<div id="add-ride-modal" class="fixed inset-0 z-50 hidden overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen p-4">
<!-- Background overlay -->
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" aria-hidden="true"></div>
<!-- Modal panel -->
<div class="relative w-full max-w-3xl p-6 mx-auto bg-white rounded-lg shadow-xl dark:bg-gray-800">
<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
Add Ride at {{ park.name }}
</h2>
</div>
<div id="modal-content">
{% include "rides/partials/ride_form.html" with modal=True %}
</div>
</div>
</div>
</div>
<!-- Modal Toggle Button -->
<button type="button"
onclick="openModal('add-ride-modal')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Add Ride
</button>
<script>
function openModal(modalId) {
document.getElementById(modalId).classList.remove('hidden');
}
function closeModal() {
document.getElementById('add-ride-modal').classList.add('hidden');
}
// Close modal when clicking outside
document.getElementById('add-ride-modal').addEventListener('click', function(event) {
if (event.target === this) {
closeModal();
}
});
</script>

View File

@@ -0,0 +1,110 @@
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Left Column -->
<div class="space-y-6">
<div>
<label for="id_height_ft" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Height (ft)
</label>
<input type="number"
name="height_ft"
id="id_height_ft"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Total height of the coaster in feet"
min="0">
</div>
<div>
<label for="id_length_ft" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Length (ft)
</label>
<input type="number"
name="length_ft"
id="id_length_ft"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Total track length in feet"
min="0">
</div>
<div>
<label for="id_speed_mph" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Speed (mph)
</label>
<input type="number"
name="speed_mph"
id="id_speed_mph"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Maximum speed in miles per hour"
min="0">
</div>
<div>
<label for="id_inversions" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Inversions
</label>
<input type="number"
name="inversions"
id="id_inversions"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Number of inversions"
min="0">
</div>
</div>
<!-- Right Column -->
<div class="space-y-6">
<div>
<label for="id_track_material" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Track Material
</label>
<select name="track_material"
id="id_track_material"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">Select track material...</option>
<option value="STEEL">Steel</option>
<option value="WOOD">Wood</option>
<option value="HYBRID">Hybrid</option>
<option value="OTHER">Other</option>
</select>
</div>
<div>
<label for="id_roller_coaster_type" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Coaster Type
</label>
<select name="roller_coaster_type"
id="id_roller_coaster_type"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">Select coaster type...</option>
<option value="SITDOWN">Sit-Down</option>
<option value="INVERTED">Inverted</option>
<option value="FLYING">Flying</option>
<option value="STANDUP">Stand-Up</option>
<option value="WING">Wing</option>
<option value="SUSPENDED">Suspended</option>
<option value="BOBSLED">Bobsled</option>
<option value="PIPELINE">Pipeline</option>
<option value="MOTORBIKE">Motorbike</option>
<option value="FLOORLESS">Floorless</option>
<option value="DIVE">Dive</option>
<option value="FAMILY">Family</option>
<option value="WILD_MOUSE">Wild Mouse</option>
<option value="SPINNING">Spinning</option>
<option value="FOURTH_DIMENSION">4th Dimension</option>
<option value="OTHER">Other</option>
</select>
</div>
<div>
<label for="id_launch_type" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Launch Type
</label>
<select name="launch_type"
id="id_launch_type"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">Select launch type...</option>
<option value="CHAIN">Chain Lift</option>
<option value="CABLE">Cable Launch</option>
<option value="HYDRAULIC">Hydraulic Launch</option>
<option value="LSM">Linear Synchronous Motor</option>
<option value="LIM">Linear Induction Motor</option>
<option value="GRAVITY">Gravity</option>
<option value="OTHER">Other</option>
</select>
</div>
</div>
</div>

View File

@@ -0,0 +1,93 @@
{% load static %}
<div class="bg-white rounded-lg shadow-xl modal-content dark:bg-gray-800"
x-data="{ showModal: true }"
@click.outside="$dispatch('close-modal')"
@keydown.escape.window="$dispatch('close-modal')">
<div class="p-6">
<h2 class="mb-4 text-xl font-semibold dark:text-white">Create New Ride Model</h2>
<form hx-post="{% url 'rides:create_ride_model' %}"
hx-target="#modal-content"
class="space-y-4">
{% csrf_token %}
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name *</label>
<input type="text"
id="name"
name="name"
value="{{ name }}"
required
class="w-full mt-1 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white">
</div>
<div>
<label for="manufacturer" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Manufacturer</label>
<select id="manufacturer"
name="manufacturer"
class="w-full mt-1 border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">Select a manufacturer</option>
{% for manufacturer in manufacturers %}
<option value="{{ manufacturer.id }}">{{ manufacturer.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Category *</label>
<select id="category"
name="category"
required
class="w-full mt-1 border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">Select a category</option>
{% for code, name in categories %}
<option value="{{ code }}">{{ name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<textarea id="description"
name="description"
rows="3"
class="w-full mt-1 border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Enter a description of this ride model"></textarea>
</div>
{% if not user.is_privileged %}
<div>
<label for="reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Reason for Addition *</label>
<textarea id="reason"
name="reason"
rows="2"
required
class="w-full mt-1 border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Why are you adding this ride model?"></textarea>
</div>
<div>
<label for="source" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Source</label>
<input type="text"
id="source"
name="source"
class="w-full mt-1 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="URL or reference for this information">
</div>
{% endif %}
<div class="flex justify-end mt-6 space-x-3">
<button type="button"
@click="$dispatch('close-modal')"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:hover:bg-gray-600">
Cancel
</button>
<button type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 dark:hover:bg-blue-500">
Create Ride Model
</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,26 @@
{% if error %}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
{{ error }}
</div>
{% else %}
<div class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800" role="alert">
{% if designer.id %}
Designer "{{ designer.name }}" has been created successfully.
<script>
// Update the designer field in the parent form
selectDesigner('{{ designer.id }}', '{{ designer.name }}');
// Close the modal
document.dispatchEvent(new CustomEvent('close-designer-modal'));
</script>
{% else %}
Your designer submission "{{ designer.name }}" has been sent for review.
You will be notified when it is approved.
<script>
// Close the modal after a short delay
setTimeout(() => {
document.dispatchEvent(new CustomEvent('close-designer-modal'));
}, 2000);
</script>
{% endif %}
</div>
{% endif %}

View File

@@ -0,0 +1,84 @@
{% load static %}
<form method="post"
class="space-y-6"
x-data="{ submitting: false }"
@submit.prevent="
if (!submitting) {
submitting = true;
const formData = new FormData($event.target);
htmx.ajax('POST', '/rides/designers/create/', {
values: Object.fromEntries(formData),
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
}
}).then(response => {
if (response.detail) {
const data = JSON.parse(response.detail.xhr.response);
selectDesigner(data.id, data.name);
}
$dispatch('close-designer-modal');
}).finally(() => {
submitting = false;
});
}">
{% csrf_token %}
<div id="designer-form-notification"></div>
<div class="space-y-6">
<!-- Name -->
<div>
<label for="designer_name" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Name *
</label>
<input type="text"
name="name"
id="designer_name"
value="{{ search_term|default:'' }}"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
required>
</div>
{% if not user.is_privileged %}
<!-- Reason and Source for non-privileged users -->
<div>
<label for="reason" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Reason for Addition *
</label>
<textarea name="reason"
id="reason"
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
rows="3"
required
placeholder="Please explain why you're adding this designer and provide any relevant details."></textarea>
</div>
<div>
<label for="source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Source (Optional)
</label>
<input type="text"
name="source"
id="source"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="URL or reference for this information">
</div>
{% endif %}
</div>
<div class="flex justify-end mt-6 space-x-3">
{% if modal %}
<button type="button"
@click="$dispatch('close-designer-modal')"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:hover:bg-gray-600">
Cancel
</button>
{% endif %}
<button type="submit"
:disabled="submitting"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 dark:hover:bg-blue-500 disabled:opacity-50">
<span x-show="!submitting">Create Designer</span>
<span x-show="submitting">Creating...</span>
</button>
</div>
</form>

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 designers %}
{% for designer in designers %}
<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="selectDesigner('{{ designer.id }}', '{{ designer.name|escapejs }}')">
{{ designer.name }}
</button>
{% endfor %}
{% else %}
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
{% if search_term %}
No matches found. You can still submit this name.
{% else %}
Start typing to search...
{% endif %}
</div>
{% endif %}
</div>
<script>
function selectDesigner(id, name) {
document.getElementById('id_designer').value = id;
document.getElementById('id_designer_search').value = name;
document.getElementById('designer-search-results').innerHTML = '';
}
</script>

View File

@@ -0,0 +1,465 @@
{% load static %}
<!-- Advanced Ride Filters Sidebar -->
<div class="filter-sidebar bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 h-full overflow-y-auto">
<!-- Filter Header -->
<div class="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 p-4 z-10">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
<i class="fas fa-filter mr-2 text-blue-600 dark:text-blue-400"></i>
Filters
</h2>
<div class="flex items-center space-x-2">
<!-- Filter Count Badge -->
{% if has_filters %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ active_filters|length }} active
</span>
{% endif %}
<!-- Clear All Filters -->
{% if has_filters %}
<button type="button"
hx-get="{% url 'rides:global_ride_list' %}"
hx-target="#filter-results"
hx-swap="outerHTML"
hx-push-url="true"
class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 font-medium">
Clear All
</button>
{% endif %}
</div>
</div>
<!-- Active Filters Summary -->
{% if has_filters %}
<div class="mt-3 space-y-1">
{% for category, filters in active_filters.items %}
{% if filters %}
<div class="flex flex-wrap gap-1">
{% for filter_name, filter_value in filters.items %}
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
{{ filter_name }}: {{ filter_value }}
<button type="button"
class="ml-1 h-3 w-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
onclick="removeFilter('{{ category }}', '{{ filter_name }}')">
<i class="fas fa-times text-xs"></i>
</button>
</span>
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<!-- Filter Form -->
<form id="filter-form"
hx-get="{% url 'rides:global_ride_list' %}"
hx-target="#filter-results"
hx-swap="outerHTML"
hx-trigger="change, input delay:500ms"
hx-push-url="true"
class="space-y-1">
<!-- Search Text Filter -->
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button"
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
data-target="search-section">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-search mr-2 text-gray-500"></i>
Search
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
</div>
</button>
<div id="search-section" class="filter-content p-4 space-y-3">
{{ filter_form.search_text.label_tag }}
{{ filter_form.search_text }}
<div class="mt-2">
<label class="flex items-center text-sm">
{{ filter_form.search_exact }}
<span class="ml-2 text-gray-600 dark:text-gray-400">Exact match</span>
</label>
</div>
</div>
</div>
<!-- Basic Info Filter -->
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button"
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
data-target="basic-section">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-info-circle mr-2 text-gray-500"></i>
Basic Info
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
</div>
</button>
<div id="basic-section" class="filter-content p-4 space-y-4">
<!-- Categories -->
<div>
{{ filter_form.categories.label_tag }}
{{ filter_form.categories }}
</div>
<!-- Status -->
<div>
{{ filter_form.status.label_tag }}
{{ filter_form.status }}
</div>
<!-- Parks -->
<div>
{{ filter_form.parks.label_tag }}
{{ filter_form.parks }}
</div>
</div>
</div>
<!-- Date Filters -->
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button"
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
data-target="date-section">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-calendar mr-2 text-gray-500"></i>
Date Ranges
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
</div>
</button>
<div id="date-section" class="filter-content p-4 space-y-4">
<!-- Opening Date Range -->
<div>
{{ filter_form.opening_date_range.label_tag }}
{{ filter_form.opening_date_range }}
</div>
<!-- Closing Date Range -->
<div>
{{ filter_form.closing_date_range.label_tag }}
{{ filter_form.closing_date_range }}
</div>
</div>
</div>
<!-- Height & Safety -->
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button"
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
data-target="height-section">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-ruler-vertical mr-2 text-gray-500"></i>
Height & Safety
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
</div>
</button>
<div id="height-section" class="filter-content p-4 space-y-4">
<!-- Height Requirements -->
<div>
{{ filter_form.height_requirements.label_tag }}
{{ filter_form.height_requirements }}
</div>
<!-- Height Range -->
<div>
{{ filter_form.height_range.label_tag }}
{{ filter_form.height_range }}
</div>
<!-- Accessibility -->
<div>
{{ filter_form.accessibility_features.label_tag }}
{{ filter_form.accessibility_features }}
</div>
</div>
</div>
<!-- Performance -->
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button"
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
data-target="performance-section">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-tachometer-alt mr-2 text-gray-500"></i>
Performance
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
</div>
</button>
<div id="performance-section" class="filter-content p-4 space-y-4">
<!-- Speed Range -->
<div>
{{ filter_form.speed_range.label_tag }}
{{ filter_form.speed_range }}
</div>
<!-- Duration Range -->
<div>
{{ filter_form.duration_range.label_tag }}
{{ filter_form.duration_range }}
</div>
<!-- Capacity Range -->
<div>
{{ filter_form.capacity_range.label_tag }}
{{ filter_form.capacity_range }}
</div>
<!-- Intensity -->
<div>
{{ filter_form.intensity_levels.label_tag }}
{{ filter_form.intensity_levels }}
</div>
</div>
</div>
<!-- Relationships -->
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button"
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
data-target="relationships-section">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-sitemap mr-2 text-gray-500"></i>
Companies & Models
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
</div>
</button>
<div id="relationships-section" class="filter-content p-4 space-y-4">
<!-- Manufacturers -->
<div>
{{ filter_form.manufacturers.label_tag }}
{{ filter_form.manufacturers }}
</div>
<!-- Designers -->
<div>
{{ filter_form.designers.label_tag }}
{{ filter_form.designers }}
</div>
<!-- Ride Models -->
<div>
{{ filter_form.ride_models.label_tag }}
{{ filter_form.ride_models }}
</div>
</div>
</div>
<!-- Roller Coaster Specific -->
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
<button type="button"
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
data-target="coaster-section">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-mountain mr-2 text-gray-500"></i>
Roller Coaster Details
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
</div>
</button>
<div id="coaster-section" class="filter-content p-4 space-y-4">
<!-- Track Type -->
<div>
{{ filter_form.track_types.label_tag }}
{{ filter_form.track_types }}
</div>
<!-- Launch Types -->
<div>
{{ filter_form.launch_types.label_tag }}
{{ filter_form.launch_types }}
</div>
<!-- Inversions Range -->
<div>
{{ filter_form.inversions_range.label_tag }}
{{ filter_form.inversions_range }}
</div>
<!-- Drop Range -->
<div>
{{ filter_form.drop_range.label_tag }}
{{ filter_form.drop_range }}
</div>
<!-- Length Range -->
<div>
{{ filter_form.length_range.label_tag }}
{{ filter_form.length_range }}
</div>
<!-- Has Features -->
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Features</label>
<div class="space-y-1">
<label class="flex items-center text-sm">
{{ filter_form.has_inversions }}
<span class="ml-2 text-gray-600 dark:text-gray-400">Has Inversions</span>
</label>
<label class="flex items-center text-sm">
{{ filter_form.has_launch }}
<span class="ml-2 text-gray-600 dark:text-gray-400">Has Launch System</span>
</label>
</div>
</div>
</div>
</div>
<!-- Sorting -->
<div class="filter-section">
<button type="button"
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
data-target="sorting-section">
<div class="flex items-center justify-between">
<span class="flex items-center">
<i class="fas fa-sort mr-2 text-gray-500"></i>
Sorting
</span>
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
</div>
</button>
<div id="sorting-section" class="filter-content p-4 space-y-4">
<!-- Sort By -->
<div>
{{ filter_form.sort_by.label_tag }}
{{ filter_form.sort_by }}
</div>
<!-- Sort Order -->
<div>
{{ filter_form.sort_order.label_tag }}
{{ filter_form.sort_order }}
</div>
</div>
</div>
</form>
</div>
<!-- Filter JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize collapsible sections
initializeFilterSections();
// Initialize filter form handlers
initializeFilterForm();
});
function initializeFilterSections() {
const toggles = document.querySelectorAll('.filter-toggle');
toggles.forEach(toggle => {
toggle.addEventListener('click', function() {
const targetId = this.getAttribute('data-target');
const content = document.getElementById(targetId);
const chevron = this.querySelector('.fa-chevron-down');
if (content.style.display === 'none' || content.style.display === '') {
content.style.display = 'block';
chevron.style.transform = 'rotate(180deg)';
localStorage.setItem(`filter-${targetId}`, 'open');
} else {
content.style.display = 'none';
chevron.style.transform = 'rotate(0deg)';
localStorage.setItem(`filter-${targetId}`, 'closed');
}
});
// Restore section state from localStorage
const targetId = toggle.getAttribute('data-target');
const content = document.getElementById(targetId);
const chevron = toggle.querySelector('.fa-chevron-down');
const state = localStorage.getItem(`filter-${targetId}`);
if (state === 'closed') {
content.style.display = 'none';
chevron.style.transform = 'rotate(0deg)';
} else {
content.style.display = 'block';
chevron.style.transform = 'rotate(180deg)';
}
});
}
function initializeFilterForm() {
const form = document.getElementById('filter-form');
if (!form) return;
// Handle multi-select changes
const selects = form.querySelectorAll('select[multiple]');
selects.forEach(select => {
select.addEventListener('change', function() {
// Trigger HTMX update
htmx.trigger(form, 'change');
});
});
// Handle range inputs
const rangeInputs = form.querySelectorAll('input[type="range"], input[type="number"]');
rangeInputs.forEach(input => {
input.addEventListener('input', function() {
// Debounced update
clearTimeout(this.updateTimeout);
this.updateTimeout = setTimeout(() => {
htmx.trigger(form, 'input');
}, 500);
});
});
}
function removeFilter(category, filterName) {
const form = document.getElementById('filter-form');
const input = form.querySelector(`[name*="${filterName}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = false;
} else if (input.tagName === 'SELECT') {
if (input.multiple) {
Array.from(input.options).forEach(option => option.selected = false);
} else {
input.value = '';
}
} else {
input.value = '';
}
// Trigger form update
htmx.trigger(form, 'change');
}
}
// Update filter counts
function updateFilterCounts() {
const form = document.getElementById('filter-form');
const formData = new FormData(form);
let activeCount = 0;
for (let [key, value] of formData.entries()) {
if (value && value.trim() !== '') {
activeCount++;
}
}
const badge = document.querySelector('.filter-count-badge');
if (badge) {
badge.textContent = activeCount;
badge.style.display = activeCount > 0 ? 'inline-flex' : 'none';
}
}
</script>

View File

@@ -0,0 +1,30 @@
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">History</h2>
<div class="space-y-4">
{% for record in history %}
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
<div class="mb-1 text-sm text-gray-500 dark:text-gray-400">
{{ record.pgh_created_at|date:"M d, Y H:i" }}
{% if record.pgh_context.user %}
by {{ record.pgh_context.user }}
{% endif %}
• {{ record.pgh_label }}
</div>
{% if record.diff_against_previous %}
<div class="mt-2 space-y-2">
{% for field, change in record.get_display_changes.items %}
<div class="text-sm">
<span class="font-medium">{{ field }}:</span>
<span class="text-red-600 dark:text-red-400">{{ change.old }}</span>
<span class="mx-1"></span>
<span class="text-green-600 dark:text-green-400">{{ change.new }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% empty %}
<p class="text-gray-500 dark:text-gray-400">No history available.</p>
{% endfor %}
</div>
</div>

View File

@@ -0,0 +1,26 @@
{% if error %}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
{{ error }}
</div>
{% else %}
<div class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800" role="alert">
{% if manufacturer.id %}
Manufacturer "{{ manufacturer.name }}" has been created successfully.
<script>
// Update the manufacturer field in the parent form
selectManufacturer('{{ manufacturer.id }}', '{{ manufacturer.name }}');
// Close the modal
document.dispatchEvent(new CustomEvent('close-manufacturer-modal'));
</script>
{% else %}
Your manufacturer submission "{{ manufacturer.name }}" has been sent for review.
You will be notified when it is approved.
<script>
// Close the modal after a short delay
setTimeout(() => {
document.dispatchEvent(new CustomEvent('close-manufacturer-modal'));
}, 2000);
</script>
{% endif %}
</div>
{% endif %}

View File

@@ -0,0 +1,84 @@
{% load static %}
<form method="post"
class="space-y-6"
x-data="{ submitting: false }"
@submit.prevent="
if (!submitting) {
submitting = true;
const formData = new FormData($event.target);
htmx.ajax('POST', '/rides/manufacturers/create/', {
values: Object.fromEntries(formData),
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
}
}).then(response => {
if (response.detail) {
const data = JSON.parse(response.detail.xhr.response);
selectManufacturer(data.id, data.name);
}
$dispatch('close-manufacturer-modal');
}).finally(() => {
submitting = false;
});
}">
{% csrf_token %}
<div id="manufacturer-form-notification"></div>
<div class="space-y-6">
<!-- Name -->
<div>
<label for="manufacturer_name" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Name *
</label>
<input type="text"
name="name"
id="manufacturer_name"
value="{{ search_term|default:'' }}"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
required>
</div>
{% if not user.is_privileged %}
<!-- Reason and Source for non-privileged users -->
<div>
<label for="reason" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Reason for Addition *
</label>
<textarea name="reason"
id="reason"
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
rows="3"
required
placeholder="Please explain why you're adding this manufacturer and provide any relevant details."></textarea>
</div>
<div>
<label for="source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Source (Optional)
</label>
<input type="text"
name="source"
id="source"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="URL or reference for this information">
</div>
{% endif %}
</div>
<div class="flex justify-end mt-6 space-x-3">
{% if modal %}
<button type="button"
@click="$dispatch('close-manufacturer-modal')"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:hover:bg-gray-600">
Cancel
</button>
{% endif %}
<button type="submit"
:disabled="submitting"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 dark:hover:bg-blue-500 disabled:opacity-50">
<span x-show="!submitting">Create Manufacturer</span>
<span x-show="submitting">Creating...</span>
</button>
</div>
</form>

View File

@@ -0,0 +1,33 @@
<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 manufacturers %}
{% for manufacturer in manufacturers %}
<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="selectManufacturer('{{ manufacturer.id }}', '{{ manufacturer.name|escapejs }}')">
{{ manufacturer.name }}
</button>
{% endfor %}
{% else %}
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
{% if search_term %}
No matches found. You can still submit this name.
{% else %}
Start typing to search...
{% endif %}
</div>
{% endif %}
</div>
<script>
function selectManufacturer(id, name) {
document.getElementById('id_manufacturer').value = id;
document.getElementById('id_manufacturer_search').value = name;
document.getElementById('manufacturer-search-results').innerHTML = '';
// Update ride model search to include manufacturer
const rideModelSearch = document.getElementById('id_ride_model_search');
if (rideModelSearch) {
rideModelSearch.setAttribute('hx-include', '[name="manufacturer"]');
}
}
</script>

View File

@@ -0,0 +1,291 @@
{% load static %}
<script>
function selectManufacturer(id, name) {
document.getElementById('id_manufacturer').value = id;
document.getElementById('id_manufacturer_search').value = name;
document.getElementById('manufacturer-search-results').innerHTML = '';
// Update ride model search to include manufacturer
const rideModelSearch = document.getElementById('id_ride_model_search');
if (rideModelSearch) {
rideModelSearch.setAttribute('hx-include', '[name="manufacturer"]');
}
}
function selectDesigner(id, name) {
document.getElementById('id_designer').value = id;
document.getElementById('id_designer_search').value = name;
document.getElementById('designer-search-results').innerHTML = '';
}
function selectRideModel(id, name) {
document.getElementById('id_ride_model').value = id;
document.getElementById('id_ride_model_search').value = name;
document.getElementById('ride-model-search-results').innerHTML = '';
}
// Handle form submission
document.addEventListener('submit', function(e) {
if (e.target.id === 'ride-form') {
// Clear search results
document.getElementById('manufacturer-search-results').innerHTML = '';
document.getElementById('designer-search-results').innerHTML = '';
document.getElementById('ride-model-search-results').innerHTML = '';
}
});
// Handle clicks outside search results
document.addEventListener('click', function(e) {
const manufacturerResults = document.getElementById('manufacturer-search-results');
const designerResults = document.getElementById('designer-search-results');
const rideModelResults = document.getElementById('ride-model-search-results');
if (!e.target.closest('#manufacturer-search-container')) {
manufacturerResults.innerHTML = '';
}
if (!e.target.closest('#designer-search-container')) {
designerResults.innerHTML = '';
}
if (!e.target.closest('#ride-model-search-container')) {
rideModelResults.innerHTML = '';
}
});
</script>
<form method="post" id="ride-form" class="space-y-6" enctype="multipart/form-data">
{% csrf_token %}
<!-- Park Area -->
{% if form.park_area %}
<div class="space-y-2">
<label for="{{ form.park_area.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Park Area
</label>
{{ form.park_area }}
{% if form.park_area.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.park_area.errors }}
</div>
{% endif %}
</div>
{% endif %}
<!-- Name -->
<div class="space-y-2">
<label for="{{ form.name.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Name *
</label>
{{ form.name }}
{% if form.name.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.name.errors }}
</div>
{% endif %}
</div>
<!-- Manufacturer -->
<div class="space-y-2">
<div id="manufacturer-search-container" class="relative">
<label for="{{ form.manufacturer_search.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Manufacturer
</label>
{{ form.manufacturer_search }}
{{ form.manufacturer }}
<div id="manufacturer-search-results" class="relative"></div>
{% if form.manufacturer.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.manufacturer.errors }}
</div>
{% endif %}
</div>
</div>
<!-- Designer -->
<div class="space-y-2">
<div id="designer-search-container" class="relative">
<label for="{{ form.designer_search.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Designer
</label>
{{ form.designer_search }}
{{ form.designer }}
<div id="designer-search-results" class="relative"></div>
{% if form.designer.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.designer.errors }}
</div>
{% endif %}
</div>
</div>
<!-- Ride Model -->
<div class="space-y-2">
<div id="ride-model-search-container" class="relative">
<label for="{{ form.ride_model_search.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Ride Model
</label>
{{ form.ride_model_search }}
{{ form.ride_model }}
<div id="ride-model-search-results" class="relative"></div>
{% if form.ride_model.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.ride_model.errors }}
</div>
{% endif %}
</div>
</div>
<!-- Model Name -->
<div class="space-y-2">
<label for="{{ form.model_name.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Model Name
</label>
{{ form.model_name }}
{% if form.model_name.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.model_name.errors }}
</div>
{% endif %}
</div>
<!-- Category -->
<div class="space-y-2">
<label for="{{ form.category.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Category *
</label>
{{ form.category }}
{% if form.category.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.category.errors }}
</div>
{% endif %}
</div>
<!-- Coaster Fields -->
<div id="coaster-fields"></div>
<!-- Status -->
<div class="space-y-2">
<label for="{{ form.status.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Status
</label>
{{ form.status }}
{% if form.status.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.status.errors }}
</div>
{% endif %}
</div>
<!-- Opening Date -->
<div class="space-y-2">
<label for="{{ form.opening_date.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Opening Date
</label>
{{ form.opening_date }}
{% if form.opening_date.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.opening_date.errors }}
</div>
{% endif %}
</div>
<!-- Closing Date -->
<div class="space-y-2">
<label for="{{ form.closing_date.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Closing Date
</label>
{{ form.closing_date }}
{% if form.closing_date.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.closing_date.errors }}
</div>
{% endif %}
</div>
<!-- Status Since -->
<div class="space-y-2">
<label for="{{ form.status_since.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Status Since
</label>
{{ form.status_since }}
{% if form.status_since.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.status_since.errors }}
</div>
{% endif %}
</div>
<!-- Min Height -->
<div class="space-y-2">
<label for="{{ form.min_height_in.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Minimum Height (inches)
</label>
{{ form.min_height_in }}
{% if form.min_height_in.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.min_height_in.errors }}
</div>
{% endif %}
</div>
<!-- Max Height -->
<div class="space-y-2">
<label for="{{ form.max_height_in.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Maximum Height (inches)
</label>
{{ form.max_height_in }}
{% if form.max_height_in.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.max_height_in.errors }}
</div>
{% endif %}
</div>
<!-- Capacity -->
<div class="space-y-2">
<label for="{{ form.capacity_per_hour.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Hourly Capacity
</label>
{{ form.capacity_per_hour }}
{% if form.capacity_per_hour.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.capacity_per_hour.errors }}
</div>
{% endif %}
</div>
<!-- Ride Duration -->
<div class="space-y-2">
<label for="{{ form.ride_duration_seconds.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Ride Duration (seconds)
</label>
{{ form.ride_duration_seconds }}
{% if form.ride_duration_seconds.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.ride_duration_seconds.errors }}
</div>
{% endif %}
</div>
<!-- Description -->
<div class="space-y-2">
<label for="{{ form.description.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
{{ form.description }}
{% if form.description.errors %}
<div class="text-sm text-red-600 dark:text-red-400">
{{ form.description.errors }}
</div>
{% endif %}
</div>
<!-- Submit Button -->
<div class="flex justify-end">
<button type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600">
{% if is_edit %}Save Changes{% else %}Add Ride{% endif %}
</button>
</div>
</form>

View File

@@ -0,0 +1,63 @@
{% for ride in rides %}
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
{% if ride.photos.exists %}
<div class="aspect-w-16 aspect-h-9">
<img src="{{ ride.photos.first.image.url }}"
alt="{{ ride.name }}"
class="object-cover w-full">
</div>
{% endif %}
<div class="p-4">
<h2 class="mb-2 text-xl font-bold">
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ ride.name }}
</a>
</h2>
{% if not park %}
<p class="mb-3 text-gray-600 dark:text-gray-400">
at <a href="{% url 'parks:park_detail' ride.park.slug %}"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
{{ ride.park.name }}
</a>
</p>
{% endif %}
<div class="flex flex-wrap gap-2">
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-400/30 dark:text-blue-200 dark:ring-1 dark:ring-blue-400/30">
{{ ride.get_category_display }}
</span>
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif ride.status == 'DEMOLISHED' %}status-demolished
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ ride.get_status_display }}
</span>
{% if ride.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>
{{ ride.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</div>
{% if ride.coaster_stats %}
<div class="grid grid-cols-2 gap-2 mt-4">
{% if ride.coaster_stats.height_ft %}
<div class="text-sm text-gray-600 dark:text-gray-400">
Height: {{ ride.coaster_stats.height_ft }}ft
</div>
{% endif %}
{% if ride.coaster_stats.speed_mph %}
<div class="text-sm text-gray-600 dark:text-gray-400">
Speed: {{ ride.coaster_stats.speed_mph }}mph
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% empty %}
<div class="col-span-3 py-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No rides found matching your criteria.</p>
</div>
{% endfor %}

View File

@@ -0,0 +1,326 @@
<!-- Active filters display (mobile and desktop) -->
{% if has_filters %}
<div class="mb-6">
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-blue-900 dark:text-blue-100">
<i class="fas fa-filter mr-2"></i>
Active Filters ({{ active_filters|length }})
</h3>
<button hx-get="{% url 'rides:global_ride_list' %}{% if park %}?park={{ park.id }}{% endif %}"
hx-target="#filter-results"
hx-push-url="true"
class="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 transition-colors">
<i class="fas fa-times mr-1"></i>
Clear All
</button>
</div>
<div class="flex flex-wrap gap-2">
{% for filter_key, filter_info in active_filters.items %}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100">
{{ filter_info.label }}: {{ filter_info.value }}
<button hx-get="{{ filter_info.remove_url }}"
hx-target="#filter-results"
hx-push-url="true"
class="ml-1 text-blue-600 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-100">
<i class="fas fa-times text-xs"></i>
</button>
</span>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Results header with count and sorting -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6">
<div class="mb-4 sm:mb-0">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{% if filtered_count == total_rides %}
All {{ total_rides }} ride{{ total_rides|pluralize }}
{% else %}
{{ filtered_count }} of {{ total_rides }} ride{{ total_rides|pluralize }}
{% endif %}
{% if park %}
at {{ park.name }}
{% endif %}
</h2>
{% if query %}
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
<i class="fas fa-search mr-1"></i>
Searching for: "{{ query }}"
</p>
{% endif %}
</div>
<!-- Sort options -->
<div class="flex items-center space-x-2">
<label for="sort-select" class="text-sm font-medium text-gray-700 dark:text-gray-300">Sort by:</label>
<select id="sort-select"
name="sort"
hx-get="{% url 'rides:global_ride_list' %}"
hx-target="#filter-results"
hx-include="[name='q'], [name='category'], [name='manufacturer'], [name='designer'], [name='min_height'], [name='max_height'], [name='min_speed'], [name='max_speed'], [name='min_capacity'], [name='max_capacity'], [name='min_duration'], [name='max_duration'], [name='opened_after'], [name='opened_before'], [name='closed_after'], [name='closed_before'], [name='operating_status'], [name='has_inversions'], [name='has_launches'], [name='track_type'], [name='min_inversions'], [name='max_inversions'], [name='min_launches'], [name='max_launches'], [name='min_top_speed'], [name='max_top_speed'], [name='min_max_height'], [name='max_max_height']{% if park %}, [name='park']{% endif %}"
hx-push-url="true"
class="form-select text-sm">
<option value="name" {% if current_sort == 'name' %}selected{% endif %}>Name (A-Z)</option>
<option value="-name" {% if current_sort == '-name' %}selected{% endif %}>Name (Z-A)</option>
<option value="-opened_date" {% if current_sort == '-opened_date' %}selected{% endif %}>Newest First</option>
<option value="opened_date" {% if current_sort == 'opened_date' %}selected{% endif %}>Oldest First</option>
<option value="-height" {% if current_sort == '-height' %}selected{% endif %}>Tallest First</option>
<option value="height" {% if current_sort == 'height' %}selected{% endif %}>Shortest First</option>
<option value="-max_speed" {% if current_sort == '-max_speed' %}selected{% endif %}>Fastest First</option>
<option value="max_speed" {% if current_sort == 'max_speed' %}selected{% endif %}>Slowest First</option>
<option value="-capacity_per_hour" {% if current_sort == '-capacity_per_hour' %}selected{% endif %}>Highest Capacity</option>
<option value="capacity_per_hour" {% if current_sort == 'capacity_per_hour' %}selected{% endif %}>Lowest Capacity</option>
<option value="relevance" {% if current_sort == 'relevance' %}selected{% endif %}>Most Relevant</option>
</select>
</div>
</div>
<!-- Results grid -->
{% if rides %}
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{% for ride in rides %}
<div class="ride-card bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-lg transition-all duration-300">
<!-- Ride image -->
<div class="relative h-48 bg-gradient-to-br from-blue-500 to-purple-600">
{% if ride.image %}
<img src="{{ ride.image.url }}"
alt="{{ ride.name }}"
class="w-full h-full object-cover">
{% else %}
<div class="flex items-center justify-center h-full">
<i class="fas fa-rocket text-4xl text-white opacity-50"></i>
</div>
{% endif %}
<!-- Status badge -->
<div class="absolute top-3 right-3">
{% if ride.operating_status == 'operating' %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<i class="fas fa-play-circle mr-1"></i>
Operating
</span>
{% elif ride.operating_status == 'closed_temporarily' %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
<i class="fas fa-pause-circle mr-1"></i>
Temporarily Closed
</span>
{% elif ride.operating_status == 'closed_permanently' %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
<i class="fas fa-stop-circle mr-1"></i>
Permanently Closed
</span>
{% elif ride.operating_status == 'under_construction' %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<i class="fas fa-hard-hat mr-1"></i>
Under Construction
</span>
{% endif %}
</div>
</div>
<!-- Ride details -->
<div class="p-5">
<!-- Name and category -->
<div class="mb-3">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
<a href="{% url 'rides:ride_detail' ride.id %}"
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
{{ ride.name }}
</a>
</h3>
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 mr-2">
{{ ride.category|default:"Ride" }}
</span>
{% if ride.park %}
<span class="flex items-center">
<i class="fas fa-map-marker-alt mr-1"></i>
{{ ride.park.name }}
</span>
{% endif %}
</div>
</div>
<!-- Key stats -->
<div class="grid grid-cols-2 gap-3 mb-4">
{% if ride.height %}
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.height }}ft</div>
<div class="text-xs text-gray-600 dark:text-gray-400">Height</div>
</div>
{% endif %}
{% if ride.rollercoaster_stats.max_speed %}
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.rollercoaster_stats.max_speed }}mph</div>
<div class="text-xs text-gray-600 dark:text-gray-400">Top Speed</div>
</div>
{% elif ride.max_speed %}
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.max_speed }}mph</div>
<div class="text-xs text-gray-600 dark:text-gray-400">Max Speed</div>
</div>
{% endif %}
{% if ride.capacity_per_hour %}
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.capacity_per_hour }}</div>
<div class="text-xs text-gray-600 dark:text-gray-400">Capacity/Hr</div>
</div>
{% endif %}
{% if ride.duration %}
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.duration }}s</div>
<div class="text-xs text-gray-600 dark:text-gray-400">Duration</div>
</div>
{% endif %}
</div>
<!-- Special features -->
{% if ride.has_inversions or ride.has_launches or ride.rollercoaster_stats %}
<div class="flex flex-wrap gap-1 mb-3">
{% if ride.has_inversions %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
<i class="fas fa-sync-alt mr-1"></i>
{% if ride.rollercoaster_stats.number_of_inversions %}
{{ ride.rollercoaster_stats.number_of_inversions }} Inversion{{ ride.rollercoaster_stats.number_of_inversions|pluralize }}
{% else %}
Inversions
{% endif %}
</span>
{% endif %}
{% if ride.has_launches %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
<i class="fas fa-rocket mr-1"></i>
{% if ride.rollercoaster_stats.number_of_launches %}
{{ ride.rollercoaster_stats.number_of_launches }} Launch{{ ride.rollercoaster_stats.number_of_launches|pluralize }}
{% else %}
Launched
{% endif %}
</span>
{% endif %}
{% if ride.rollercoaster_stats.track_type %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ ride.rollercoaster_stats.track_type|title }}
</span>
{% endif %}
</div>
{% endif %}
<!-- Opening date -->
{% if ride.opened_date %}
<div class="text-sm text-gray-600 dark:text-gray-400 mb-3">
<i class="fas fa-calendar mr-1"></i>
Opened {{ ride.opened_date|date:"F j, Y" }}
</div>
{% endif %}
<!-- Manufacturer -->
{% if ride.manufacturer %}
<div class="text-sm text-gray-600 dark:text-gray-400">
<i class="fas fa-industry mr-1"></i>
{{ ride.manufacturer.name }}
{% if ride.designer and ride.designer != ride.manufacturer %}
• Designed by {{ ride.designer.name }}
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if is_paginated %}
<div class="mt-8 flex items-center justify-between">
<div class="flex items-center text-sm text-gray-700 dark:text-gray-300">
<p>
Showing {{ page_obj.start_index }} to {{ page_obj.end_index }} of {{ filtered_count }} results
</p>
</div>
<div class="flex items-center space-x-2">
{% if page_obj.has_previous %}
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}"
hx-get="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}"
hx-target="#filter-results"
hx-push-url="true"
class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600">
<i class="fas fa-chevron-left mr-1"></i>
Previous
</a>
{% endif %}
<!-- Page numbers -->
<div class="hidden sm:flex items-center space-x-1">
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<span class="inline-flex items-center px-3 py-2 border border-blue-500 bg-blue-50 text-blue-600 rounded-md text-sm font-medium dark:bg-blue-900 dark:text-blue-200 dark:border-blue-700">
{{ num }}
</span>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ num }}"
hx-get="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ num }}"
hx-target="#filter-results"
hx-push-url="true"
class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600">
{{ num }}
</a>
{% endif %}
{% endfor %}
</div>
{% if page_obj.has_next %}
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}"
hx-get="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}"
hx-target="#filter-results"
hx-push-url="true"
class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600">
Next
<i class="fas fa-chevron-right ml-1"></i>
</a>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
<!-- No results state -->
<div class="text-center py-12">
<div class="mx-auto h-24 w-24 text-gray-400 dark:text-gray-600 mb-4">
<i class="fas fa-search text-6xl"></i>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No rides found</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">
{% if has_filters %}
Try adjusting your filters to see more results.
{% else %}
No rides match your current search criteria.
{% endif %}
</p>
{% if has_filters %}
<button hx-get="{% url 'rides:global_ride_list' %}{% if park %}?park={{ park.id }}{% endif %}"
hx-target="#filter-results"
hx-push-url="true"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 transition-colors">
<i class="fas fa-times mr-2"></i>
Clear All Filters
</button>
{% endif %}
</div>
{% endif %}
<!-- HTMX loading indicator -->
<div class="htmx-indicator fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 z-50">
<div class="flex items-center space-x-3">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span class="text-sm font-medium text-gray-900 dark:text-white">Loading results...</span>
</div>
</div>

View File

@@ -0,0 +1,44 @@
{% if error %}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
<span class="font-medium">Error:</span> {{ error }}
</div>
{% elif ride_model.id %}
<div class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800" role="alert">
<span class="font-medium">Success!</span> Ride model "{{ ride_model.name }}" has been created.
</div>
<script>
// Update the ride model field in the parent form
const rideModelId = '{{ ride_model.id }}';
const rideModelName = '{{ ride_model.name|escapejs }}';
document.getElementById('id_ride_model').value = rideModelId;
document.getElementById('id_ride_model_search').value = rideModelName;
// Close the modal after a short delay to allow the user to see the success message
setTimeout(() => {
document.dispatchEvent(new CustomEvent('close-ride-model-modal'));
// Clear the notification after the modal closes
setTimeout(() => {
document.getElementById('ride-model-notification').innerHTML = '';
}, 300);
}, 1000);
</script>
{% else %}
<div class="p-4 mb-4 text-sm text-yellow-700 bg-yellow-100 rounded-lg dark:bg-yellow-200 dark:text-yellow-800" role="alert">
<span class="font-medium">Note:</span> Your submission has been sent for review. You will be notified when it is approved.
</div>
<script>
// Close the modal after a short delay to allow the user to see the message
setTimeout(() => {
document.dispatchEvent(new CustomEvent('close-ride-model-modal'));
// Clear the notification after the modal closes
setTimeout(() => {
document.getElementById('ride-model-notification').innerHTML = '';
}, 300);
}, 2000);
</script>
{% endif %}

View File

@@ -0,0 +1,215 @@
{% load static %}
<form method="post"
class="space-y-6"
x-data="{
submitting: false,
manufacturerSearchTerm: '',
setManufacturerModal(value, term = '') {
const parentForm = document.querySelector('[x-data]');
if (parentForm) {
const parentData = Alpine.$data(parentForm);
if (parentData && parentData.setManufacturerModal) {
parentData.setManufacturerModal(value, term);
}
}
}
}"
@submit.prevent="
if (!submitting) {
submitting = true;
const formData = new FormData($event.target);
htmx.ajax('POST', '/rides/models/create/', {
values: Object.fromEntries(formData),
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
}
}).then(response => {
if (response.detail) {
const data = JSON.parse(response.detail.xhr.response);
selectRideModel(data.id, data.name);
}
const parentForm = document.querySelector('[x-data]');
if (parentForm) {
const parentData = Alpine.$data(parentForm);
if (parentData && parentData.setRideModelModal) {
parentData.setRideModelModal(false);
}
}
}).finally(() => {
submitting = false;
});
}">
{% csrf_token %}
<div id="ride-model-notification"></div>
<div class="space-y-6">
<!-- Name -->
<div>
<label for="{{ form.name.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ form.name.label }}{% if form.name.field.required %} *{% endif %}
</label>
<input type="text"
name="name"
id="{{ form.name.id_for_label }}"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
required>
</div>
<!-- Manufacturer Search -->
<div>
<label for="{{ form.manufacturer_search.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ form.manufacturer_search.label }}
</label>
<div class="relative">
<div id="manufacturer-notification" class="mb-2"></div>
<div class="flex gap-2">
<div class="flex-grow">
<input type="text"
id="{{ form.manufacturer_search.id_for_label }}"
name="manufacturer_search"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search for a manufacturer..."
hx-get="/rides/search/manufacturers/"
hx-trigger="click, input changed delay:200ms"
hx-target="#manufacturer-search-results"
autocomplete="off"
@input="manufacturerSearchTerm = $event.target.value"
{% if prefilled_manufacturer %}
value="{{ prefilled_manufacturer.name }}"
readonly
{% endif %}>
</div>
{% if not prefilled_manufacturer and not create_ride_model %}
<button type="button"
class="px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
@click.prevent="setManufacturerModal(true, manufacturerSearchTerm)">
<i class="fas fa-plus"></i>
</button>
{% endif %}
</div>
<div id="manufacturer-search-results" class="absolute z-50 w-full"></div>
</div>
<input type="hidden"
name="manufacturer"
id="{{ form.manufacturer.id_for_label }}"
{% if prefilled_manufacturer %}
value="{{ prefilled_manufacturer.id }}"
{% endif %}>
</div>
<!-- Category -->
<div>
<label for="{{ form.category.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ form.category.label }}{% if form.category.field.required %} *{% endif %}
</label>
<select name="category"
id="{{ form.category.id_for_label }}"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white"
required>
{% for value, label in form.category.field.choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<!-- Description -->
<div>
<label for="{{ form.description.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ form.description.label }}{% if form.description.field.required %} *{% endif %}
</label>
<textarea name="description"
id="{{ form.description.id_for_label }}"
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
rows="4"></textarea>
</div>
{% if not user.is_privileged %}
<!-- Reason and Source for non-privileged users -->
<div>
<label for="reason" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Reason for Addition *
</label>
<textarea name="reason"
id="reason"
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
rows="3"
required
placeholder="Please explain why you're adding this ride model and provide any relevant details."></textarea>
</div>
<div>
<label for="source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Source (Optional)
</label>
<input type="text"
name="source"
id="source"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="URL or reference for this information">
</div>
{% endif %}
</div>
<div class="flex justify-end mt-6 space-x-3">
{% if modal %}
<button type="button"
@click="$dispatch('close-ride-model-modal')"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:hover:bg-gray-600">
Cancel
</button>
{% endif %}
<button type="submit"
:disabled="submitting"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 dark:hover:bg-blue-500 disabled:opacity-50">
<span x-show="!submitting">Create Ride Model</span>
<span x-show="submitting">Creating...</span>
</button>
</div>
</form>
<script>
function selectManufacturer(manufacturerId, manufacturerName) {
// Update the hidden manufacturer field
document.getElementById('id_manufacturer').value = manufacturerId;
// Update the search input with the manufacturer name
document.getElementById('id_manufacturer_search').value = manufacturerName;
// Clear the search results
document.getElementById('manufacturer-search-results').innerHTML = '';
}
// Close search results when clicking outside
document.addEventListener('click', function(event) {
// Get the parent form element that contains the Alpine.js data
const formElement = event.target.closest('form[x-data]');
if (!formElement) return;
// Get Alpine.js data from the form
const formData = formElement.__x.$data;
// Don't handle clicks if manufacturer modal is open
if (formData.showManufacturerModal) {
return;
}
const searchResults = [
{ input: 'id_manufacturer_search', results: 'manufacturer-search-results' }
];
searchResults.forEach(function(item) {
const input = document.getElementById(item.input);
const results = document.getElementById(item.results);
if (results && !results.contains(event.target) && event.target !== input) {
results.innerHTML = '';
}
});
});
// Initialize form with any pre-filled values
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('id_ride_model_search');
if (searchInput && searchInput.value) {
document.getElementById('id_name').value = searchInput.value;
}
});
</script>

View File

@@ -0,0 +1,38 @@
<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 not manufacturer_id %}
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
Please select a manufacturer first
</div>
{% else %}
{% if ride_models %}
{% for ride_model in ride_models %}
<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="selectRideModel('{{ ride_model.id }}', '{{ ride_model.name|escapejs }}')">
{{ ride_model.name }}
{% if ride_model.manufacturer %}
<div class="text-sm text-gray-700 dark:text-gray-300">
by {{ ride_model.manufacturer.name }}
</div>
{% endif %}
</button>
{% endfor %}
{% else %}
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
{% if search_term %}
No matches found. You can still submit this name.
{% else %}
Start typing to search...
{% endif %}
</div>
{% endif %}
{% endif %}
</div>
<script>
function selectRideModel(id, name) {
document.getElementById('id_ride_model').value = id;
document.getElementById('id_ride_model_search').value = name;
document.getElementById('ride-model-search-results').innerHTML = '';
}
</script>

View File

@@ -0,0 +1,416 @@
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('rideSearch', () => ({
init() {
// Initialize from URL params
const urlParams = new URLSearchParams(window.location.search);
this.searchQuery = urlParams.get('search') || '';
// Bind to form reset
document.querySelector('form').addEventListener('reset', () => {
this.searchQuery = '';
this.showSuggestions = false;
this.selectedIndex = -1;
this.cleanup();
});
// Handle clicks outside suggestions
document.addEventListener('click', (e) => {
if (!e.target.closest('#search-suggestions') && !e.target.closest('#search')) {
this.showSuggestions = false;
}
});
// Handle HTMX errors
document.body.addEventListener('htmx:error', (evt) => {
console.error('HTMX Error:', evt.detail.error);
this.showError('An error occurred while searching. Please try again.');
});
// Store bound handlers for cleanup
this.boundHandlers = new Map();
// Create handler functions
const popstateHandler = () => {
const urlParams = new URLSearchParams(window.location.search);
this.searchQuery = urlParams.get('search') || '';
this.syncFormWithUrl();
};
this.boundHandlers.set('popstate', popstateHandler);
const errorHandler = (evt) => {
console.error('HTMX Error:', evt.detail.error);
this.showError('An error occurred while searching. Please try again.');
};
this.boundHandlers.set('htmx:error', errorHandler);
// Bind event listeners
window.addEventListener('popstate', popstateHandler);
document.body.addEventListener('htmx:error', errorHandler);
// Restore filters from localStorage if no URL params exist
const savedFilters = localStorage.getItem('rideFilters');
// Set up destruction handler
this.$cleanup = this.performCleanup.bind(this);
if (savedFilters) {
const filters = JSON.parse(savedFilters);
Object.entries(filters).forEach(([key, value]) => {
const input = document.querySelector(`[name="${key}"]`);
if (input) input.value = value;
});
// Trigger search with restored filters
document.querySelector('form').dispatchEvent(new Event('change'));
}
// Set up filter persistence
document.querySelector('form').addEventListener('change', (e) => {
this.saveFilters();
});
},
showSuggestions: false,
loading: false,
searchQuery: '',
suggestionTimeout: null,
// Save current filters to localStorage
saveFilters() {
const form = document.querySelector('form');
const formData = new FormData(form);
const filters = {};
for (let [key, value] of formData.entries()) {
if (value) filters[key] = value;
}
localStorage.setItem('rideFilters', JSON.stringify(filters));
},
// Clear all filters
clearFilters() {
document.querySelectorAll('form select, form input').forEach(el => {
el.value = '';
});
localStorage.removeItem('rideFilters');
document.querySelector('form').dispatchEvent(new Event('change'));
},
// Get search suggestions with request tracking
lastRequestId: 0,
currentRequest: null,
async getSearchSuggestions() {
if (this.searchQuery.length < 2) {
this.showSuggestions = false;
return;
}
// Cancel any pending request
if (this.currentRequest) {
this.currentRequest.abort();
}
const requestId = ++this.lastRequestId;
const controller = new AbortController();
this.currentRequest = controller;
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
try {
const response = await this.fetchSuggestions(controller, requestId);
await this.handleSuggestionResponse(response, requestId);
} catch (error) {
this.handleSuggestionError(error, requestId);
} finally {
clearTimeout(timeoutId);
if (this.currentRequest === controller) {
this.currentRequest = null;
}
}
},
async fetchSuggestions(controller, requestId) {
const parkSlug = document.querySelector('input[name="park_slug"]')?.value;
const url = `/rides/search-suggestions/?q=${encodeURIComponent(this.searchQuery)}${parkSlug ? '&park_slug=' + parkSlug : ''}`;
const response = await fetch(url, {
signal: controller.signal,
headers: {
'X-Request-ID': requestId.toString()
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response;
},
async handleSuggestionResponse(response, requestId) {
const html = await response.text();
if (requestId === this.lastRequestId && this.searchQuery === document.getElementById('search').value) {
const suggestionsEl = document.getElementById('search-suggestions');
suggestionsEl.innerHTML = html;
this.showSuggestions = Boolean(html.trim());
this.updateAriaAttributes(suggestionsEl);
}
},
updateAriaAttributes(suggestionsEl) {
const searchInput = document.getElementById('search');
searchInput.setAttribute('aria-expanded', this.showSuggestions.toString());
searchInput.setAttribute('aria-controls', 'search-suggestions');
if (this.showSuggestions) {
suggestionsEl.setAttribute('role', 'listbox');
suggestionsEl.querySelectorAll('button').forEach(btn => {
btn.setAttribute('role', 'option');
});
}
},
handleSuggestionError(error, requestId) {
if (error.name === 'AbortError') {
console.warn('Search suggestion request timed out or cancelled');
return;
}
console.error('Error fetching suggestions:', error);
if (requestId === this.lastRequestId) {
const suggestionsEl = document.getElementById('search-suggestions');
suggestionsEl.innerHTML = `
<div class="p-2 text-sm text-red-600 dark:text-red-400" role="alert">
Failed to load suggestions. Please try again.
</div>`;
this.showSuggestions = true;
}
},
// Handle input changes with debounce
async handleInput() {
clearTimeout(this.suggestionTimeout);
this.suggestionTimeout = setTimeout(() => {
this.getSearchSuggestions();
}, 200);
},
// Handle suggestion selection
// Sync form with URL parameters
syncFormWithUrl() {
const urlParams = new URLSearchParams(window.location.search);
const form = document.querySelector('form');
// Clear existing values
form.querySelectorAll('input, select').forEach(el => {
if (el.type !== 'hidden') el.value = '';
});
// Set values from URL
urlParams.forEach((value, key) => {
const input = form.querySelector(`[name="${key}"]`);
if (input) input.value = value;
});
// Trigger form update
form.dispatchEvent(new Event('change'));
},
// Cleanup resources
cleanup() {
clearTimeout(this.suggestionTimeout);
this.showSuggestions = false;
localStorage.removeItem('rideFilters');
},
selectSuggestion(text) {
this.searchQuery = text;
this.showSuggestions = false;
document.getElementById('search').value = text;
// Update URL with search parameter
const url = new URL(window.location);
url.searchParams.set('search', text);
window.history.pushState({}, '', url);
document.querySelector('form').dispatchEvent(new Event('change'));
},
// Handle keyboard navigation
// Show error message
showError(message) {
const searchInput = document.getElementById('search');
const errorDiv = document.createElement('div');
errorDiv.className = 'text-red-600 text-sm mt-1';
errorDiv.textContent = message;
searchInput.parentNode.appendChild(errorDiv);
setTimeout(() => errorDiv.remove(), 3000);
},
// Handle keyboard navigation
handleKeydown(e) {
const suggestions = document.querySelectorAll('#search-suggestions button');
if (!suggestions.length) return;
const currentIndex = Array.from(suggestions).findIndex(el => el === document.activeElement);
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
if (currentIndex < 0) {
suggestions[0].focus();
this.selectedIndex = 0;
} else if (currentIndex < suggestions.length - 1) {
suggestions[currentIndex + 1].focus();
this.selectedIndex = currentIndex + 1;
}
break;
case 'ArrowUp':
e.preventDefault();
if (currentIndex > 0) {
suggestions[currentIndex - 1].focus();
this.selectedIndex = currentIndex - 1;
} else {
document.getElementById('search').focus();
this.selectedIndex = -1;
}
break;
case 'Escape':
this.showSuggestions = false;
this.selectedIndex = -1;
document.getElementById('search').blur();
break;
case 'Enter':
if (document.activeElement.tagName === 'BUTTON') {
e.preventDefault();
this.selectSuggestion(document.activeElement.dataset.text);
}
break;
case 'Tab':
this.showSuggestions = false;
break;
}
}
}));
});
},
performCleanup() {
// Remove all bound event listeners
this.boundHandlers.forEach(this.removeEventHandler.bind(this));
this.boundHandlers.clear();
// Cancel any pending requests
if (this.currentRequest) {
this.currentRequest.abort();
this.currentRequest = null;
}
// Clear any pending timeouts
if (this.suggestionTimeout) {
clearTimeout(this.suggestionTimeout);
}
},
removeEventHandler(handler, event) {
if (event === 'popstate') {
window.removeEventListener(event, handler);
} else {
document.body.removeEventListener(event, handler);
}
}
}));
});
</script>
<!-- HTMX Loading Indicator Styles -->
<style>
.htmx-indicator {
opacity: 0;
transition: opacity 200ms ease-in;
}
.htmx-request .htmx-indicator {
opacity: 1
}
/* Enhanced Loading Indicator */
.loading-indicator {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 50;
display: flex;
align-items: center;
gap: 0.5rem;
color: white;
font-size: 0.875rem;
}
.loading-indicator svg {
width: 1.25rem;
height: 1.25rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
<script>
// Initialize request timeout management
const timeouts = new Map();
// Handle request start
document.addEventListener('htmx:beforeRequest', function(evt) {
const timestamp = document.querySelector('.loading-timestamp');
if (timestamp) {
timestamp.textContent = new Date().toLocaleTimeString();
}
// Set timeout for request
const timeoutId = setTimeout(() => {
evt.detail.xhr.abort();
showError('Request timed out. Please try again.');
}, 10000); // 10s timeout
timeouts.set(evt.detail.xhr, timeoutId);
});
// Handle request completion
document.addEventListener('htmx:afterRequest', function(evt) {
const timeoutId = timeouts.get(evt.detail.xhr);
if (timeoutId) {
clearTimeout(timeoutId);
timeouts.delete(evt.detail.xhr);
}
if (!evt.detail.successful) {
showError('Failed to update results. Please try again.');
}
});
// Handle errors
function showError(message) {
const indicator = document.querySelector('.loading-indicator');
if (indicator) {
indicator.innerHTML = `
<div class="flex items-center text-red-100">
<i class="mr-2 fas fa-exclamation-circle"></i>
<span>${message}</span>
</div>`;
setTimeout(() => {
indicator.innerHTML = originalIndicatorContent;
}, 3000);
}
}
// Store original indicator content
const originalIndicatorContent = document.querySelector('.loading-indicator')?.innerHTML;
// Reset loading state when navigating away
window.addEventListener('beforeunload', () => {
timeouts.forEach(timeoutId => clearTimeout(timeoutId));
timeouts.clear();
});
</script>

View File

@@ -0,0 +1,26 @@
{% if suggestions %}
<div class="py-2">
{% for suggestion in suggestions %}
<button class="block w-full px-4 py-2 text-left text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 focus:outline-hidden focus:bg-gray-100 dark:focus:bg-gray-700"
@click="selectSuggestion('{{ suggestion.text }}')"
@keydown.enter="selectSuggestion('{{ suggestion.text }}')"
@keydown.esc="showSuggestions = false"
data-text="{{ suggestion.text }}"
tabindex="0">
<div class="flex items-center">
{% if suggestion.type == 'park' %}
<i class="w-6 mr-2 text-gray-400 fa fa-map-marker-alt"></i>
{% elif suggestion.type == 'category' %}
<i class="w-6 mr-2 text-gray-400 fa fa-ticket-alt"></i>
{% else %}
<i class="w-6 mr-2 text-gray-400 fa fa-search"></i>
{% endif %}
<span>{{ suggestion.text }}</span>
{% if suggestion.subtext %}
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">{{ suggestion.subtext }}</span>
{% endif %}
</div>
</button>
{% endfor %}
</div>
{% endif %}

View File

@@ -0,0 +1,154 @@
{% extends "base/base.html" %}
{% load static %}
{% load ride_tags %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container px-4 mx-auto">
<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">{{ title }}</h1>
{% if park %}
<a href="{% url 'parks:park_detail' park.slug %}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
Back to {{ park.name }}
</a>
{% endif %}
</div>
</div>
<!-- Category Filters -->
<div class="flex flex-wrap gap-4 mb-8">
{% if park %}
<a href="{% url 'parks:park_roller_coasters' park.slug %}"
class="px-4 py-2 rounded-lg transition-colors {% if category_code == 'RC' %}bg-blue-600 text-white dark:bg-blue-500{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600{% endif %}">
Roller Coasters
</a>
<a href="{% url 'parks:park_dark_rides' park.slug %}"
class="px-4 py-2 rounded-lg transition-colors {% if category_code == 'DR' %}bg-blue-600 text-white dark:bg-blue-500{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600{% endif %}">
Dark Rides
</a>
<a href="{% url 'parks:park_flat_rides' park.slug %}"
class="px-4 py-2 rounded-lg transition-colors {% if category_code == 'FR' %}bg-blue-600 text-white dark:bg-blue-500{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600{% endif %}">
Flat Rides
</a>
<a href="{% url 'parks:park_water_rides' park.slug %}"
class="px-4 py-2 rounded-lg transition-colors {% if category_code == 'WR' %}bg-blue-600 text-white dark:bg-blue-500{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600{% endif %}">
Water Rides
</a>
<a href="{% url 'parks:park_transports' park.slug %}"
class="px-4 py-2 rounded-lg transition-colors {% if category_code == 'TR' %}bg-blue-600 text-white dark:bg-blue-500{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600{% endif %}">
Transports
</a>
<a href="{% url 'parks:park_others' park.slug %}"
class="px-4 py-2 rounded-lg transition-colors {% if category_code == 'OT' %}bg-blue-600 text-white dark:bg-blue-500{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600{% endif %}">
Others
</a>
{% else %}
<a href="{% url 'rides:roller_coasters' %}"
class="px-4 py-2 rounded-lg transition-colors {% if category_code == 'RC' %}bg-blue-600 text-white dark:bg-blue-500{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600{% endif %}">
Roller Coasters
</a>
<a href="{% url 'rides:dark_rides' %}"
class="px-4 py-2 rounded-lg transition-colors {% if category_code == 'DR' %}bg-blue-600 text-white dark:bg-blue-500{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600{% endif %}">
Dark Rides
</a>
<a href="{% url 'rides:flat_rides' %}"
class="px-4 py-2 rounded-lg transition-colors {% if category_code == 'FR' %}bg-blue-600 text-white dark:bg-blue-500{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600{% endif %}">
Flat Rides
</a>
<a href="{% url 'rides:water_rides' %}"
class="px-4 py-2 rounded-lg transition-colors {% if category_code == 'WR' %}bg-blue-600 text-white dark:bg-blue-500{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600{% endif %}">
Water Rides
</a>
<a href="{% url 'rides:transports' %}"
class="px-4 py-2 rounded-lg transition-colors {% if category_code == 'TR' %}bg-blue-600 text-white dark:bg-blue-500{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600{% endif %}">
Transports
</a>
<a href="{% url 'rides:others' %}"
class="px-4 py-2 rounded-lg transition-colors {% if category_code == 'OT' %}bg-blue-600 text-white dark:bg-blue-500{% else %}bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600{% endif %}">
Others
</a>
{% endif %}
</div>
{% if not categories %}
<p class="text-gray-600 dark:text-gray-400">No rides found.</p>
{% endif %}
{% for category_name, rides in categories.items %}
<div class="mb-10">
<h2 class="mb-4 text-2xl font-semibold text-gray-900 dark:text-white">{{ category_name }}s</h2>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for ride in rides %}
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<div class="aspect-w-16 aspect-h-9">
{% if ride.photos.exists %}
<img src="{{ ride.photos.first.image.url }}"
alt="{{ ride.name }}"
class="object-cover w-full">
{% else %}
<img src="{% get_ride_placeholder_image ride.category %}"
alt="{{ ride.name }}"
class="object-cover w-full">
{% endif %}
</div>
<div class="p-4">
<h3 class="mb-2 text-xl font-bold">
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ ride.name }}
</a>
</h3>
{% if not park %}
<p class="mb-3 text-gray-600 dark:text-gray-400">
at <a href="{% url 'parks:park_detail' ride.park.slug %}"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
{{ ride.park.name }}
</a>
</p>
{% endif %}
{% if ride.manufacturer %}
<p class="mb-3 text-gray-600 dark:text-gray-400">{{ ride.manufacturer.name }}</p>
{% endif %}
<div class="flex flex-wrap gap-2">
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-400/30 dark:text-blue-200 dark:ring-1 dark:ring-blue-400/30">
{{ ride.get_category_display }}
</span>
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif ride.status == 'DEMOLISHED' %}status-demolished
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ ride.get_status_display }}
</span>
{% if ride.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>
{{ ride.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</div>
{% if ride.coaster_stats %}
<div class="grid grid-cols-2 gap-2 mt-4">
{% if ride.coaster_stats.height_ft %}
<div class="text-sm text-gray-600 dark:text-gray-400">
Height: {{ ride.coaster_stats.height_ft }}ft
</div>
{% endif %}
{% if ride.coaster_stats.speed_mph %}
<div class="text-sm text-gray-600 dark:text-gray-400">
Speed: {{ ride.coaster_stats.speed_mph }}mph
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,461 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}{{ ride.name }} at {{ ride.park.name }} - ThrillWiki{% endblock %}
{% block content %}
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
<!-- Action Buttons - Above header -->
{% if user.is_authenticated %}
<div class="flex justify-end gap-2 mb-2">
<a href="{% url 'parks:rides:ride_update' park_slug=ride.park.slug ride_slug=ride.slug %}"
class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-pencil-alt"></i>Edit
</a>
{% 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 %}
</div>
{% endif %}
<!-- Ride 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">{{ ride.name }}</h1>
<div class="flex items-center justify-center mt-2 text-sm text-gray-600 dark:text-gray-400">
at <a href="{% url 'parks:park_detail' ride.park.slug %}" class="ml-1 text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">
{{ ride.park.name }}
</a>
{% if ride.park_area %}
- {{ ride.park_area.name }}
{% endif %}
</div>
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
<span class="px-3 py-1 text-sm font-medium status-badge {% if ride.status == 'OPERATING' %}status-operating
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif ride.status == 'DEMOLISHED' %}status-demolished
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ ride.get_status_display }}
</span>
<span class="px-3 py-1 text-sm font-medium 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 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>
{{ ride.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</div>
</div>
</div>
<!-- Horizontal Stats Bar -->
<div class="grid grid-cols-2 gap-4 mb-6 md:grid-cols-3 lg:grid-cols-5">
<!-- Statistics Card -->
<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">Statistics</dt>
<dd class="mt-1 space-y-1">
{% if coaster_stats.height_ft %}
<div class="text-xs text-sky-900 dark:text-sky-400">{{ coaster_stats.height_ft }}ft high</div>
{% endif %}
{% if coaster_stats.speed_mph %}
<div class="text-xs text-sky-900 dark:text-sky-400">{{ coaster_stats.speed_mph }}mph</div>
{% endif %}
{% if coaster_stats.length_ft %}
<div class="text-xs text-sky-900 dark:text-sky-400">{{ coaster_stats.length_ft }}ft long</div>
{% endif %}
</dd>
</div>
</div>
<!-- Experience Card -->
<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">Experience</dt>
<dd class="mt-1 space-y-1">
<div class="text-xs text-sky-900 dark:text-sky-400">{{ ride.get_category_display }}</div>
{% if coaster_stats.ride_time_seconds %}
<div class="text-xs text-sky-900 dark:text-sky-400">{{ coaster_stats.ride_time_seconds }}s ride</div>
{% endif %}
{% if ride.min_height_in %}
<div class="text-xs text-sky-900 dark:text-sky-400">{{ ride.min_height_in }}" min</div>
{% endif %}
</dd>
</div>
</div>
<!-- Manufacturer Card -->
<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">Manufacturer</dt>
<dd class="mt-1">
{% if ride.manufacturer %}
<a href="#"
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
{{ ride.manufacturer.name }}
</a>
{% else %}
<span class="text-xs text-sky-900 dark:text-sky-400">Unknown</span>
{% endif %}
{% if ride.model_name %}
<div class="text-xs text-sky-900 dark:text-sky-400 mt-1">{{ ride.model_name }}</div>
{% endif %}
</dd>
</div>
</div>
<!-- History Card -->
<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">History</dt>
<dd class="mt-1 space-y-1">
{% if ride.opening_date %}
<div class="text-xs text-sky-900 dark:text-sky-400">Opened {{ ride.opening_date }}</div>
{% endif %}
{% if ride.designer %}
<div class="text-xs text-sky-900 dark:text-sky-400">by {{ ride.designer.name }}</div>
{% endif %}
{% if ride.status_since %}
<div class="text-xs text-sky-900 dark:text-sky-400">{{ ride.get_status_display }} since {{ ride.status_since }}</div>
{% endif %}
</dd>
</div>
</div>
<!-- Performance Card -->
<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">Performance</dt>
<dd class="mt-1 space-y-1">
{% if ride.average_rating %}
<div class="text-xs text-sky-900 dark:text-sky-400">★ {{ ride.average_rating|floatformat:1 }}/10</div>
{% endif %}
{% if ride.capacity_per_hour %}
<div class="text-xs text-sky-900 dark:text-sky-400">{{ ride.capacity_per_hour }}/hr</div>
{% endif %}
{% if coaster_stats.inversions %}
<div class="text-xs text-sky-900 dark:text-sky-400">{{ coaster_stats.inversions }} inversions</div>
{% endif %}
</dd>
</div>
</div>
</div>
<!-- Rest of the content remains unchanged -->
{% if ride.photos.exists %}
<div class="p-6 mb-8 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
{% include "media/partials/photo_display.html" with photos=ride.photos.all content_type="rides.ride" object_id=ride.id %}
</div>
{% endif %}
<!-- Reviews Section -->
<div class="p-6 mb-8 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">Reviews</h2>
{% if user.is_authenticated %}
<button class="btn-primary">
<i class="mr-2 fas fa-star"></i>
Write a Review
</button>
{% endif %}
</div>
{% if ride.reviews.exists %}
<div class="space-y-4">
{% for review in ride.reviews.all %}
<div class="pb-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-start justify-between">
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">{{ review.title }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
by {{ review.user.username }} on {{ review.created_at|date }}
</p>
</div>
<div class="flex items-center">
<span class="mr-1 text-yellow-400"></span>
<span class="text-gray-900 dark:text-white">{{ review.rating }}/10</span>
</div>
</div>
<p class="mt-2 text-gray-700 dark:text-gray-300">{{ review.content }}</p>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500 dark:text-gray-400">No reviews yet. Be the first to review this ride!</p>
{% endif %}
</div>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left Column - Description and Details -->
<div class="lg:col-span-2">
{% if ride.description %}
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Trivia</h2>
<div class="prose dark:prose-invert max-w-none">
{{ ride.description|linebreaks }}
</div>
</div>
{% endif %}
{% if ride.previous_names %}
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Previous Names</h2>
<div class="space-y-2">
{% for name_history in ride.previous_names %}
<div class="flex justify-between">
<span class="text-gray-900 dark:text-white">{{ name_history.name }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ name_history.period }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if coaster_stats %}
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Roller Coaster Statistics</h2>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3">
<!-- Coaster Type -->
{% if coaster_stats.roller_coaster_type %}
<div>
<span class="block text-gray-500 dark:text-gray-400">Coaster Type</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.get_roller_coaster_type_display }}
</span>
</div>
{% endif %}
<!-- Height Stats -->
{% if coaster_stats.height_ft %}
<div>
<span class="block text-gray-500 dark:text-gray-400">Maximum Height</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.height_ft }} ft
</span>
</div>
{% endif %}
{% if coaster_stats.max_drop_height_ft %}
<div>
<span class="block text-gray-500 dark:text-gray-400">Drop Height</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.max_drop_height_ft }} ft
</span>
</div>
{% endif %}
<!-- Track Stats -->
{% if coaster_stats.length_ft %}
<div>
<span class="block text-gray-500 dark:text-gray-400">Track Length</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.length_ft }} ft
</span>
</div>
{% endif %}
{% if coaster_stats.track_type %}
<div>
<span class="block text-gray-500 dark:text-gray-400">Track Layout</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.track_type }}
</span>
</div>
{% endif %}
{% if coaster_stats.track_material %}
<div>
<span class="block text-gray-500 dark:text-gray-400">Track Material</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.get_track_material_display }}
</span>
</div>
{% endif %}
<!-- Speed and Time -->
{% if coaster_stats.speed_mph %}
<div>
<span class="block text-gray-500 dark:text-gray-400">Maximum Speed</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.speed_mph }} mph
</span>
</div>
{% endif %}
{% if coaster_stats.ride_time_seconds %}
<div>
<span class="block text-gray-500 dark:text-gray-400">Ride Duration</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.ride_time_seconds }} sec
</span>
</div>
{% endif %}
<!-- Train Details -->
{% if coaster_stats.train_style %}
<div>
<span class="block text-gray-500 dark:text-gray-400">Train Style</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.train_style }}
</span>
</div>
{% endif %}
{% if coaster_stats.trains_count %}
<div>
<span class="block text-gray-500 dark:text-gray-400">Number of Trains</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.trains_count }}
</span>
</div>
{% endif %}
{% if coaster_stats.cars_per_train %}
<div>
<span class="block text-gray-500 dark:text-gray-400">Cars per Train</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.cars_per_train }}
</span>
</div>
{% endif %}
{% if coaster_stats.seats_per_car %}
<div>
<span class="block text-gray-500 dark:text-gray-400">Seats per Car</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.seats_per_car }}
</span>
</div>
{% endif %}
<!-- Other Stats -->
{% if coaster_stats.inversions %}
<div>
<span class="block text-gray-500 dark:text-gray-400">Inversions</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.inversions }}
</span>
</div>
{% endif %}
{% if coaster_stats.launch_type %}
<div>
<span class="block text-gray-500 dark:text-gray-400">Launch Type</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.get_launch_type_display }}
</span>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
<!-- Right Column - Quick Facts -->
<div class="lg:col-span-1">
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Quick Facts</h2>
<dl class="space-y-4">
<div>
<dt class="text-gray-500 dark:text-gray-400">Manufacturer</dt>
<dd class="font-medium text-gray-900 dark:text-white">
{% if ride.manufacturer %}
<a href="#"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
{{ ride.manufacturer.name }}
</a>
{% else %}
<span class="text-gray-500 dark:text-gray-400">Not specified</span>
{% endif %}
</dd>
</div>
{% if ride.designer %}
<div>
<dt class="text-gray-500 dark:text-gray-400">Designer</dt>
<dd class="font-medium text-gray-900 dark:text-white">
<a href="{% url 'designers:designer_detail' ride.designer.slug %}"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
{{ ride.designer.name }}
</a>
</dd>
</div>
{% endif %}
{% if ride.model_name %}
<div>
<dt class="text-gray-500 dark:text-gray-400">Model</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ ride.model_name }}</dd>
</div>
{% endif %}
{% if ride.opening_date %}
<div>
<dt class="text-gray-500 dark:text-gray-400">Opening Date</dt>
<dd class="font-medium text-gray-900 dark:text-white">
{{ ride.opening_date }}
</dd>
</div>
{% endif %}
{% if ride.status_since %}
<div>
<dt class="text-gray-500 dark:text-gray-400">Status Since</dt>
<dd class="font-medium text-gray-900 dark:text-white">
{{ ride.status_since }}
</dd>
</div>
{% endif %}
{% if ride.closing_date %}
<div>
<dt class="text-gray-500 dark:text-gray-400">Closing Date</dt>
<dd class="font-medium text-gray-900 dark:text-white">
{{ ride.closing_date }}
</dd>
</div>
{% endif %}
{% if ride.capacity_per_hour %}
<div>
<dt class="text-gray-500 dark:text-gray-400">Capacity</dt>
<dd class="font-medium text-gray-900 dark:text-white">
{{ ride.capacity_per_hour }} riders/hour
</dd>
</div>
{% endif %}
{% if ride.min_height_in %}
<div>
<dt class="text-gray-500 dark:text-gray-400">Minimum Height</dt>
<dd class="font-medium text-gray-900 dark:text-white">
{{ ride.min_height_in }} inches
</dd>
</div>
{% endif %}
</dl>
</div>
<!-- History Panel -->
{% include "rides/partials/history_panel.html" with history=history %}
</div>
</div>
</div>
<!-- Photo Upload Modal -->
{% if perms.media.add_photo %}
<div x-cloak
x-data="{ show: false }"
@show-photo-upload.window="show = true"
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="rides.ride" object_id=ride.id %}
</div>
</div>
{% endif %}
{% endblock content %}
{% block extra_js %}
<script src="{% static 'js/photo-gallery.js' %}"></script>
{% endblock %}

View File

@@ -0,0 +1,245 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}{% if is_edit %}Edit{% else %}Add{% endif %} Ride - ThrillWiki{% 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 {{ object.name }}{% else %}Add New Ride{% endif %}
{% if park %}
<div class="mt-2 text-lg font-normal text-gray-600 dark:text-gray-400">
at <a href="{% url 'parks:park_detail' park.slug %}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">{{ park.name }}</a>
</div>
{% endif %}
</h1>
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="{
status: '{{ form.instance.status|default:'OPERATING' }}',
clearResults(containerId) {
const container = document.getElementById(containerId);
if (container && !container.contains(event.target)) {
container.querySelector('[id$=search-results]').innerHTML = '';
}
},
handleStatusChange(event) {
this.status = event.target.value;
if (this.status === 'CLOSING') {
document.getElementById('id_closing_date').required = true;
} else {
document.getElementById('id_closing_date').required = false;
}
},
showClosingDate() {
return ['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(this.status);
}
}">
{% csrf_token %}
{% if not park %}
{# Park Selection - Only shown when creating from global view #}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold">Park Information</h2>
<div class="space-y-4">
<div id="park-search-container" class="relative" @click.outside="clearResults('park-search-container')">
<label for="{{ form.park_search.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Park *
</label>
{{ form.park_search }}
{{ form.park }}
<div id="park-search-results" class="relative"></div>
{% if form.park.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form.park.errors }}
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
{# 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 for="{{ form.name.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Name *
</label>
{{ form.name }}
{% if form.name.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form.name.errors }}
</div>
{% endif %}
</div>
<div class="col-span-2">
<label for="{{ form.category.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Category *
</label>
{{ form.category }}
{% if form.category.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form.category.errors }}
</div>
{% endif %}
</div>
<div id="coaster-fields" class="col-span-2"></div>
</div>
</div>
{# Manufacturer and Model #}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold">Manufacturer and Model</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div id="manufacturer-search-container" class="relative" @click.outside="clearResults('manufacturer-search-container')">
<label for="{{ form.manufacturer_search.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Manufacturer
</label>
{{ form.manufacturer_search }}
{{ form.manufacturer }}
<div id="manufacturer-search-results" class="relative"></div>
</div>
<div id="designer-search-container" class="relative" @click.outside="clearResults('designer-search-container')">
<label for="{{ form.designer_search.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Designer
</label>
{{ form.designer_search }}
{{ form.designer }}
<div id="designer-search-results" class="relative"></div>
</div>
<div id="ride-model-search-container" class="relative col-span-2" @click.outside="clearResults('ride-model-search-container')">
<label for="{{ form.ride_model_search.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Ride Model
</label>
{{ form.ride_model_search }}
{{ form.ride_model }}
<div id="ride-model-search-results" class="relative"></div>
</div>
</div>
</div>
{# Status and Dates #}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold">Status and Dates</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="{{ form.status.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Status
</label>
{{ form.status }}
</div>
<div>
<label for="{{ form.status_since.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Status Since
</label>
{{ form.status_since }}
</div>
<div>
<label for="{{ form.opening_date.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Opening Date
</label>
{{ form.opening_date }}
</div>
<div x-show="showClosingDate()">
<label for="{{ form.closing_date.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Closing Date <span x-show="status === 'CLOSING'" class="text-red-600">*</span>
</label>
{{ form.closing_date }}
</div>
<div x-show="status === 'CLOSING'" class="col-span-2">
<label for="{{ form.post_closing_status.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Status After Closing *
</label>
{{ form.post_closing_status }}
</div>
</div>
</div>
{# Specifications #}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold">Specifications</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="{{ form.min_height_in.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Minimum Height (inches)
</label>
{{ form.min_height_in }}
</div>
<div>
<label for="{{ form.max_height_in.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Maximum Height (inches)
</label>
{{ form.max_height_in }}
</div>
<div>
<label for="{{ form.capacity_per_hour.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Hourly Capacity
</label>
{{ form.capacity_per_hour }}
</div>
<div>
<label for="{{ form.ride_duration_seconds.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Ride Duration (seconds)
</label>
{{ form.ride_duration_seconds }}
</div>
</div>
</div>
{# Description #}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold">Description</h2>
<div>
<label for="{{ form.description.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
{{ form.description }}
</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">
{% if is_edit %}Save Changes{% else %}Add Ride{% endif %}
</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,258 @@
{% extends "base/base.html" %}
{% load static %}
{% block title %}
{% if park %}
{{ park.name }} - Rides
{% else %}
All Rides
{% endif %}
{% endblock %}
{% block extra_css %}
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
/* Custom styles for the ride filtering interface */
.filter-sidebar {
width: 320px;
min-width: 320px;
max-height: calc(100vh - 4rem);
position: sticky;
top: 4rem;
}
@media (max-width: 1024px) {
.filter-sidebar {
width: 280px;
min-width: 280px;
}
}
@media (max-width: 768px) {
.filter-sidebar {
width: 100%;
min-width: auto;
position: relative;
top: auto;
max-height: none;
}
}
.filter-toggle {
transition: all 0.2s ease-in-out;
}
.filter-toggle:hover {
background-color: rgba(59, 130, 246, 0.05);
}
.filter-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-in-out;
}
.filter-content.open {
max-height: 1000px;
}
/* Custom form styling */
.form-input, .form-select, .form-textarea {
@apply w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
dark:bg-gray-700 dark:border-gray-600 dark:text-white
dark:focus:ring-blue-400 dark:focus:border-blue-400;
}
.form-checkbox {
@apply w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded
focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800
dark:bg-gray-700 dark:border-gray-600;
}
.form-label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1;
}
/* Ride card animations */
.ride-card {
transition: all 0.3s ease-in-out;
}
.ride-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
/* Loading spinner */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: block;
}
.htmx-request.htmx-indicator {
display: block;
}
/* Mobile filter overlay */
@media (max-width: 768px) {
.mobile-filter-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 40;
}
.mobile-filter-panel {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 100%;
max-width: 320px;
background: white;
z-index: 50;
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
}
.mobile-filter-panel.open {
transform: translateX(0);
}
}
</style>
{% endblock %}
{% block content %}
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- Mobile filter overlay -->
<div id="mobile-filter-overlay" class="mobile-filter-overlay hidden lg:hidden"></div>
<!-- Header -->
<div class="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div class="max-w-full px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Title and breadcrumb -->
<div class="flex items-center space-x-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{% if park %}
<i class="fas fa-map-marker-alt mr-2 text-blue-600 dark:text-blue-400"></i>
{{ park.name }} - Rides
{% else %}
<i class="fas fa-rocket mr-2 text-blue-600 dark:text-blue-400"></i>
All Rides
{% endif %}
</h1>
<!-- Mobile filter toggle -->
<button id="mobile-filter-toggle"
class="lg:hidden inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600">
<i class="fas fa-filter mr-2"></i>
Filters
{% if has_filters %}
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ active_filters|length }}
</span>
{% endif %}
</button>
</div>
<!-- Results summary -->
<div class="hidden sm:flex items-center space-x-4 text-sm text-gray-600 dark:text-gray-400">
<span>
<i class="fas fa-list mr-1"></i>
{{ filtered_count }} of {{ total_rides }} rides
</span>
<!-- Loading indicator -->
<div class="htmx-indicator">
<i class="fas fa-spinner fa-spin text-blue-600"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Main content area -->
<div class="flex">
<!-- Desktop filter sidebar -->
<div class="hidden lg:block">
{% include "rides/partials/filter_sidebar.html" %}
</div>
<!-- Mobile filter panel -->
<div id="mobile-filter-panel" class="mobile-filter-panel lg:hidden dark:bg-gray-900">
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Filters</h3>
<button id="mobile-filter-close" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
<i class="fas fa-times"></i>
</button>
</div>
{% include "rides/partials/filter_sidebar.html" %}
</div>
<!-- Results area -->
<div class="flex-1 min-w-0">
<div id="filter-results" class="p-4 lg:p-6">
{% include "rides/partials/ride_list_results.html" %}
</div>
</div>
</div>
</div>
<!-- Mobile filter JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const mobileToggle = document.getElementById('mobile-filter-toggle');
const mobilePanel = document.getElementById('mobile-filter-panel');
const mobileOverlay = document.getElementById('mobile-filter-overlay');
const mobileClose = document.getElementById('mobile-filter-close');
function openMobileFilter() {
mobilePanel.classList.add('open');
mobileOverlay.classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function closeMobileFilter() {
mobilePanel.classList.remove('open');
mobileOverlay.classList.add('hidden');
document.body.style.overflow = '';
}
if (mobileToggle) {
mobileToggle.addEventListener('click', openMobileFilter);
}
if (mobileClose) {
mobileClose.addEventListener('click', closeMobileFilter);
}
if (mobileOverlay) {
mobileOverlay.addEventListener('click', closeMobileFilter);
}
// Close on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && mobilePanel.classList.contains('open')) {
closeMobileFilter();
}
});
});
// Dark mode toggle (if not already implemented globally)
function toggleDarkMode() {
document.documentElement.classList.toggle('dark');
localStorage.setItem('darkMode', document.documentElement.classList.contains('dark'));
}
// Initialize dark mode from localStorage
if (localStorage.getItem('darkMode') === 'true' ||
(!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
{% endblock %}