mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:11:10 -05:00
feat: complete monorepo structure with frontend and shared resources
- Add complete backend/ directory with full Django application - Add frontend/ directory with Vite + TypeScript setup ready for Next.js - Add comprehensive shared/ directory with: - Complete documentation and memory-bank archives - Media files and avatars (letters, park/ride images) - Deployment scripts and automation tools - Shared types and utilities - Add architecture/ directory with migration guides - Configure pnpm workspace for monorepo development - Update .gitignore to exclude .django_tailwind_cli/ build artifacts - Preserve all historical documentation in shared/docs/memory-bank/ - Set up proper structure for full-stack development with shared resources
This commit is contained in:
0
backend/templates/.gitkeep
Normal file
0
backend/templates/.gitkeep
Normal file
15
backend/templates/404.html
Normal file
15
backend/templates/404.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends "base/base.html" %}
|
||||
|
||||
{% block title %}Page Not Found{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 py-16 mx-auto">
|
||||
<div class="text-center">
|
||||
<h1 class="mb-4 text-4xl font-bold">404 - Page Not Found</h1>
|
||||
<p class="mb-8 text-lg">The page you're looking for doesn't exist or has been moved. Please try again in a while.</p>
|
||||
<a href="/" class="inline-block px-4 py-2 font-bold text-white bg-blue-600 rounded hover:bg-blue-700">
|
||||
Return Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
15
backend/templates/500.html
Normal file
15
backend/templates/500.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends "base/base.html" %}
|
||||
|
||||
{% block title %}Server Error{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-16">
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-bold mb-4">500 - Server Error</h1>
|
||||
<p class="text-lg mb-8">Something went wrong on our end. Please try again later.</p>
|
||||
<a href="/" class="inline-block bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Return Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
65
backend/templates/account/login.html
Normal file
65
backend/templates/account/login.html
Normal file
@@ -0,0 +1,65 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load account socialaccount %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Login - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-center py-8 md:py-12">
|
||||
<div class="w-full max-w-lg px-4">
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-title">{% trans "Welcome Back" %}</h1>
|
||||
|
||||
{% get_providers as socialaccount_providers %}
|
||||
{% if socialaccount_providers %}
|
||||
<div class="space-y-3">
|
||||
{% for provider in socialaccount_providers %}
|
||||
<a
|
||||
href="{% provider_login_url provider.id process='login' %}"
|
||||
class="btn-social {% if provider.id == 'discord' %}btn-discord{% elif provider.id == 'google' %}btn-google{% endif %}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown="if(event.key === 'Enter' || event.key === ' ') { this.click(); event.preventDefault(); }"
|
||||
>
|
||||
{% if provider.id == 'google' %}
|
||||
<img
|
||||
src="{% static 'images/google-icon.svg' %}"
|
||||
alt="Google"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span>Continue with Google</span>
|
||||
{% elif provider.id == 'discord' %}
|
||||
<img
|
||||
src="{% static 'images/discord-icon.svg' %}"
|
||||
alt="Discord"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span>Continue with Discord</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="auth-divider">
|
||||
<span>Or continue with email</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include "account/partials/login_form.html" %}
|
||||
|
||||
<div class="mt-6 text-sm text-center">
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{% trans "Don't have an account?" %}
|
||||
<a
|
||||
href="{% url 'account_signup' %}"
|
||||
class="ml-1 font-medium transition-colors text-primary hover:text-primary/80 focus:outline-hidden focus:underline"
|
||||
>
|
||||
{% trans "Sign up" %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
101
backend/templates/account/partials/login_form.html
Normal file
101
backend/templates/account/partials/login_form.html
Normal file
@@ -0,0 +1,101 @@
|
||||
{% load i18n %}
|
||||
{% load account socialaccount %}
|
||||
{% load turnstile_tags %}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
hx-post="{% url 'account_login' %}"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#login-indicator"
|
||||
>
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-error">
|
||||
<div class="text-sm">{{ form.non_field_errors }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="id_login" class="form-label">
|
||||
{% trans "Username or Email" %}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="login"
|
||||
id="id_login"
|
||||
required
|
||||
autocomplete="username email"
|
||||
class="form-input"
|
||||
/>
|
||||
{% if form.login.errors %}
|
||||
<p class="form-error">{{ form.login.errors }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_password" class="form-label">
|
||||
{% trans "Password" %}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="id_password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="form-input"
|
||||
/>
|
||||
{% if form.password.errors %}
|
||||
<p class="form-error">{{ form.password.errors }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remember"
|
||||
id="id_remember"
|
||||
class="w-4 h-4 border-gray-300 rounded text-primary focus:ring-primary/50 dark:border-gray-700"
|
||||
/>
|
||||
<label
|
||||
for="id_remember"
|
||||
class="block ml-2 text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{% trans "Remember me" %}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="text-sm">
|
||||
<a
|
||||
href="{% url 'account_reset_password' %}"
|
||||
class="font-medium transition-colors text-primary hover:text-primary/80 focus:outline-hidden focus:underline"
|
||||
>
|
||||
{% trans "Forgot Password?" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% turnstile_widget %}
|
||||
{% if redirect_field_value %}
|
||||
<input
|
||||
type="hidden"
|
||||
name="{{ redirect_field_name }}"
|
||||
value="{{ redirect_field_value }}"
|
||||
/>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<button type="submit" class="w-full btn-primary">
|
||||
<i class="mr-2 fas fa-sign-in-alt"></i>
|
||||
{% trans "Sign In" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="login-indicator" class="htmx-indicator">
|
||||
<div class="flex items-center justify-center w-full py-4">
|
||||
<div class="w-8 h-8 border-4 rounded-full border-primary border-t-transparent animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
71
backend/templates/account/partials/login_modal.html
Normal file
71
backend/templates/account/partials/login_modal.html
Normal file
@@ -0,0 +1,71 @@
|
||||
{% load i18n %}
|
||||
{% load account socialaccount %}
|
||||
{% load static %}
|
||||
|
||||
<div
|
||||
id="login-modal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto bg-black/50 backdrop-blur-xs"
|
||||
>
|
||||
<div class="w-full max-w-lg my-auto bg-white rounded-lg shadow-xl dark:bg-gray-800 max-h-[90vh] overflow-y-auto">
|
||||
<div class="sticky top-0 flex justify-between p-6 bg-white border-b dark:bg-gray-800 dark:border-gray-700">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{% trans "Welcome Back" %}</h2>
|
||||
<button
|
||||
onclick="this.closest('#login-modal').remove()"
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
{% get_providers as socialaccount_providers %}
|
||||
{% if socialaccount_providers %}
|
||||
<div class="space-y-3">
|
||||
{% for provider in socialaccount_providers %}
|
||||
<a
|
||||
href="{% provider_login_url provider.id process='login' %}"
|
||||
class="btn-social {% if provider.id == 'discord' %}btn-discord{% elif provider.id == 'google' %}btn-google{% endif %}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown="if(event.key === 'Enter' || event.key === ' ') { this.click(); event.preventDefault(); }"
|
||||
>
|
||||
{% if provider.id == 'google' %}
|
||||
<img
|
||||
src="{% static 'images/google-icon.svg' %}"
|
||||
alt="Google"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span>Continue with Google</span>
|
||||
{% elif provider.id == 'discord' %}
|
||||
<img
|
||||
src="{% static 'images/discord-icon.svg' %}"
|
||||
alt="Discord"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span>Continue with Discord</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="auth-divider">
|
||||
<span>Or continue with email</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include "account/partials/login_form.html" %}
|
||||
|
||||
<div class="mt-6 text-sm text-center">
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{% trans "Don't have an account?" %}
|
||||
<a
|
||||
href="{% url 'account_signup' %}"
|
||||
class="ml-1 font-medium transition-colors text-primary hover:text-primary/80 focus:outline-hidden focus:underline"
|
||||
>
|
||||
{% trans "Sign up" %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
263
backend/templates/account/partials/signup_modal.html
Normal file
263
backend/templates/account/partials/signup_modal.html
Normal file
@@ -0,0 +1,263 @@
|
||||
{% load i18n %}
|
||||
{% load account socialaccount %}
|
||||
{% load static %}
|
||||
{% load turnstile_tags %}
|
||||
|
||||
<div
|
||||
id="signup-modal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto bg-black/50 backdrop-blur-xs"
|
||||
@click.away="document.getElementById('signup-modal').remove()"
|
||||
>
|
||||
<div class="w-full max-w-lg my-auto bg-white rounded-lg shadow-xl dark:bg-gray-800 max-h-[90vh] overflow-y-auto">
|
||||
<div class="sticky top-0 flex justify-between p-6 bg-white border-b dark:bg-gray-800 dark:border-gray-700">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{% trans "Create Account" %}</h2>
|
||||
<button
|
||||
onclick="this.closest('#signup-modal').remove()"
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
{% get_providers as socialaccount_providers %}
|
||||
{% if socialaccount_providers %}
|
||||
<div class="space-y-3">
|
||||
{% for provider in socialaccount_providers %}
|
||||
<a
|
||||
href="{% provider_login_url provider.id process='signup' %}"
|
||||
class="btn-social {% if provider.id == 'discord' %}btn-discord{% elif provider.id == 'google' %}btn-google{% endif %}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown="if(event.key === 'Enter' || event.key === ' ') { this.click(); event.preventDefault(); }"
|
||||
>
|
||||
{% if provider.id == 'google' %}
|
||||
<img
|
||||
src="{% static 'images/google-icon.svg' %}"
|
||||
alt="Google"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span>Continue with Google</span>
|
||||
{% elif provider.id == 'discord' %}
|
||||
<img
|
||||
src="{% static 'images/discord-icon.svg' %}"
|
||||
alt="Discord"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span>Continue with Discord</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="auth-divider">
|
||||
<span>Or continue with email</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
hx-post="{% url 'account_signup' %}"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#signup-indicator"
|
||||
>
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-error">
|
||||
<div class="text-sm">{{ form.non_field_errors }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="id_username" class="form-label">
|
||||
{% trans "Username" %}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
id="id_username"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="form-input"
|
||||
/>
|
||||
{% if form.username.errors %}
|
||||
<p class="form-error">{{ form.username.errors }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_email" class="form-label">{% trans "Email" %}</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="id_email"
|
||||
required
|
||||
autocomplete="email"
|
||||
class="form-input"
|
||||
/>
|
||||
{% if form.email.errors %}
|
||||
<p class="form-error">{{ form.email.errors }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_password1" class="form-label">
|
||||
{% trans "Password" %}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password1"
|
||||
id="id_password1"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="form-input"
|
||||
oninput="validatePassword(this.value)"
|
||||
/>
|
||||
{% if form.password1.errors %}
|
||||
<p class="form-error">{{ form.password1.errors }}</p>
|
||||
{% endif %}
|
||||
<div class="mt-3 password-requirements">
|
||||
<ul id="passwordRequirements">
|
||||
<li class="invalid" id="req-length">
|
||||
<i class="text-xs fas fa-circle"></i>
|
||||
<span>Must be at least 8 characters long</span>
|
||||
</li>
|
||||
<li class="invalid" id="req-similar">
|
||||
<i class="text-xs fas fa-circle"></i>
|
||||
<span>Can't be too similar to your personal information</span>
|
||||
</li>
|
||||
<li class="invalid" id="req-common">
|
||||
<i class="text-xs fas fa-circle"></i>
|
||||
<span>Can't be a commonly used password</span>
|
||||
</li>
|
||||
<li class="invalid" id="req-numeric">
|
||||
<i class="text-xs fas fa-circle"></i>
|
||||
<span>Can't be entirely numeric</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_password2" class="form-label">
|
||||
{% trans "Confirm Password" %}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password2"
|
||||
id="id_password2"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="form-input"
|
||||
oninput="validatePasswordMatch()"
|
||||
/>
|
||||
{% if form.password2.errors %}
|
||||
<p class="form-error">{{ form.password2.errors }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% turnstile_widget %}
|
||||
{% if redirect_field_value %}
|
||||
<input
|
||||
type="hidden"
|
||||
name="{{ redirect_field_name }}"
|
||||
value="{{ redirect_field_value }}"
|
||||
/>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<button type="submit" class="w-full btn-primary">
|
||||
<i class="mr-2 fas fa-user-plus"></i>
|
||||
{% trans "Create Account" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="signup-indicator" class="htmx-indicator">
|
||||
<div class="flex items-center justify-center w-full py-4">
|
||||
<div class="w-8 h-8 border-4 rounded-full border-primary border-t-transparent animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-sm text-center">
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{% trans "Already have an account?" %}
|
||||
<a
|
||||
href="{% url 'account_login' %}"
|
||||
class="ml-1 font-medium transition-colors text-primary hover:text-primary/80 focus:outline-hidden focus:underline"
|
||||
onkeydown="if(event.key === 'Enter') { this.click(); }"
|
||||
>
|
||||
{% trans "Sign in" %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function validatePassword(password) {
|
||||
// Length requirement
|
||||
const lengthReq = document.getElementById("req-length");
|
||||
if (password.length >= 8) {
|
||||
lengthReq.classList.remove("invalid");
|
||||
lengthReq.classList.add("valid");
|
||||
lengthReq.querySelector("i").classList.remove("fa-circle");
|
||||
lengthReq.querySelector("i").classList.add("fa-check");
|
||||
} else {
|
||||
lengthReq.classList.remove("valid");
|
||||
lengthReq.classList.add("invalid");
|
||||
lengthReq.querySelector("i").classList.remove("fa-check");
|
||||
lengthReq.querySelector("i").classList.add("fa-circle");
|
||||
}
|
||||
|
||||
// Numeric requirement
|
||||
const numericReq = document.getElementById("req-numeric");
|
||||
if (!/^\d+$/.test(password)) {
|
||||
numericReq.classList.remove("invalid");
|
||||
numericReq.classList.add("valid");
|
||||
numericReq.querySelector("i").classList.remove("fa-circle");
|
||||
numericReq.querySelector("i").classList.add("fa-check");
|
||||
} else {
|
||||
numericReq.classList.remove("valid");
|
||||
numericReq.classList.add("invalid");
|
||||
numericReq.querySelector("i").classList.remove("fa-check");
|
||||
numericReq.querySelector("i").classList.add("fa-circle");
|
||||
}
|
||||
|
||||
// Common password check (basic)
|
||||
const commonReq = document.getElementById("req-common");
|
||||
const commonPasswords = ["password", "12345678", "qwerty", "letmein"];
|
||||
if (!commonPasswords.includes(password.toLowerCase())) {
|
||||
commonReq.classList.remove("invalid");
|
||||
commonReq.classList.add("valid");
|
||||
commonReq.querySelector("i").classList.remove("fa-circle");
|
||||
commonReq.querySelector("i").classList.add("fa-check");
|
||||
} else {
|
||||
commonReq.classList.remove("valid");
|
||||
commonReq.classList.add("invalid");
|
||||
commonReq.querySelector("i").classList.remove("fa-check");
|
||||
commonReq.querySelector("i").classList.add("fa-circle");
|
||||
}
|
||||
}
|
||||
|
||||
function validatePasswordMatch() {
|
||||
const password1 = document.getElementById("id_password1").value;
|
||||
const password2 = document.getElementById("id_password2").value;
|
||||
const password2Input = document.getElementById("id_password2");
|
||||
|
||||
if (password2.length > 0) {
|
||||
if (password1 === password2) {
|
||||
password2Input.classList.remove("border-red-500");
|
||||
password2Input.classList.add("border-green-500");
|
||||
} else {
|
||||
password2Input.classList.remove("border-green-500");
|
||||
password2Input.classList.add("border-red-500");
|
||||
}
|
||||
} else {
|
||||
password2Input.classList.remove("border-green-500", "border-red-500");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
243
backend/templates/account/signup.html
Normal file
243
backend/templates/account/signup.html
Normal file
@@ -0,0 +1,243 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load account socialaccount %}
|
||||
{% load static %}
|
||||
{% load turnstile_tags %}
|
||||
|
||||
{% block title %}Register - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-center py-8 md:py-12">
|
||||
<div class="w-full max-w-lg px-4">
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-title">{% trans "Create Account" %}</h1>
|
||||
|
||||
{% get_providers as socialaccount_providers %}
|
||||
{% if socialaccount_providers %}
|
||||
<div class="space-y-3">
|
||||
{% for provider in socialaccount_providers %}
|
||||
<a
|
||||
href="{% provider_login_url provider.id process='signup' %}"
|
||||
class="btn-social {% if provider.id == 'discord' %}btn-discord{% elif provider.id == 'google' %}btn-google{% endif %}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown="if(event.key === 'Enter' || event.key === ' ') { this.click(); event.preventDefault(); }"
|
||||
>
|
||||
{% if provider.id == 'google' %}
|
||||
<img
|
||||
src="{% static 'images/google-icon.svg' %}"
|
||||
alt="Google"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span>Continue with Google</span>
|
||||
{% elif provider.id == 'discord' %}
|
||||
<img
|
||||
src="{% static 'images/discord-icon.svg' %}"
|
||||
alt="Discord"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span>Continue with Discord</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="auth-divider">
|
||||
<span>Or continue with email</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="space-y-6" method="POST" action="{% url 'account_signup' %}">
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-error">
|
||||
<div class="text-sm">{{ form.non_field_errors }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="id_username" class="form-label">
|
||||
{% trans "Username" %}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
id="id_username"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="form-input"
|
||||
/>
|
||||
{% if form.username.errors %}
|
||||
<p class="form-error">{{ form.username.errors }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_email" class="form-label">{% trans "Email" %}</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="id_email"
|
||||
required
|
||||
autocomplete="email"
|
||||
class="form-input"
|
||||
/>
|
||||
{% if form.email.errors %}
|
||||
<p class="form-error">{{ form.email.errors }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_password1" class="form-label">
|
||||
{% trans "Password" %}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password1"
|
||||
id="id_password1"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="form-input"
|
||||
oninput="validatePassword(this.value)"
|
||||
/>
|
||||
{% if form.password1.errors %}
|
||||
<p class="form-error">{{ form.password1.errors }}</p>
|
||||
{% endif %}
|
||||
<div class="mt-3 password-requirements">
|
||||
<ul id="passwordRequirements">
|
||||
<li class="invalid" id="req-length">
|
||||
<i class="text-xs fas fa-circle"></i>
|
||||
<span>Must be at least 8 characters long</span>
|
||||
</li>
|
||||
<li class="invalid" id="req-similar">
|
||||
<i class="text-xs fas fa-circle"></i>
|
||||
<span>Can't be too similar to your personal information</span>
|
||||
</li>
|
||||
<li class="invalid" id="req-common">
|
||||
<i class="text-xs fas fa-circle"></i>
|
||||
<span>Can't be a commonly used password</span>
|
||||
</li>
|
||||
<li class="invalid" id="req-numeric">
|
||||
<i class="text-xs fas fa-circle"></i>
|
||||
<span>Can't be entirely numeric</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_password2" class="form-label">
|
||||
{% trans "Confirm Password" %}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password2"
|
||||
id="id_password2"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="form-input"
|
||||
oninput="validatePasswordMatch()"
|
||||
/>
|
||||
{% if form.password2.errors %}
|
||||
<p class="form-error">{{ form.password2.errors }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% turnstile_widget %}
|
||||
{% if redirect_field_value %}
|
||||
<input
|
||||
type="hidden"
|
||||
name="{{ redirect_field_name }}"
|
||||
value="{{ redirect_field_value }}"
|
||||
/>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<button type="submit" class="w-full btn-primary">
|
||||
<i class="mr-2 fas fa-user-plus"></i>
|
||||
{% trans "Create Account" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-sm text-center">
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{% trans "Already have an account?" %}
|
||||
<a
|
||||
href="{% url 'account_login' %}"
|
||||
class="ml-1 font-medium transition-colors text-primary hover:text-primary/80 focus:outline-hidden focus:underline"
|
||||
onkeydown="if(event.key === 'Enter') { this.click(); }"
|
||||
>
|
||||
{% trans "Sign in" %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function validatePassword(password) {
|
||||
// Length requirement
|
||||
const lengthReq = document.getElementById("req-length");
|
||||
if (password.length >= 8) {
|
||||
lengthReq.classList.remove("invalid");
|
||||
lengthReq.classList.add("valid");
|
||||
lengthReq.querySelector("i").classList.remove("fa-circle");
|
||||
lengthReq.querySelector("i").classList.add("fa-check");
|
||||
} else {
|
||||
lengthReq.classList.remove("valid");
|
||||
lengthReq.classList.add("invalid");
|
||||
lengthReq.querySelector("i").classList.remove("fa-check");
|
||||
lengthReq.querySelector("i").classList.add("fa-circle");
|
||||
}
|
||||
|
||||
// Numeric requirement
|
||||
const numericReq = document.getElementById("req-numeric");
|
||||
if (!/^\d+$/.test(password)) {
|
||||
numericReq.classList.remove("invalid");
|
||||
numericReq.classList.add("valid");
|
||||
numericReq.querySelector("i").classList.remove("fa-circle");
|
||||
numericReq.querySelector("i").classList.add("fa-check");
|
||||
} else {
|
||||
numericReq.classList.remove("valid");
|
||||
numericReq.classList.add("invalid");
|
||||
numericReq.querySelector("i").classList.remove("fa-check");
|
||||
numericReq.querySelector("i").classList.add("fa-circle");
|
||||
}
|
||||
|
||||
// Common password check (basic)
|
||||
const commonReq = document.getElementById("req-common");
|
||||
const commonPasswords = ["password", "12345678", "qwerty", "letmein"];
|
||||
if (!commonPasswords.includes(password.toLowerCase())) {
|
||||
commonReq.classList.remove("invalid");
|
||||
commonReq.classList.add("valid");
|
||||
commonReq.querySelector("i").classList.remove("fa-circle");
|
||||
commonReq.querySelector("i").classList.add("fa-check");
|
||||
} else {
|
||||
commonReq.classList.remove("valid");
|
||||
commonReq.classList.add("invalid");
|
||||
commonReq.querySelector("i").classList.remove("fa-check");
|
||||
commonReq.querySelector("i").classList.add("fa-circle");
|
||||
}
|
||||
}
|
||||
|
||||
function validatePasswordMatch() {
|
||||
const password1 = document.getElementById("id_password1").value;
|
||||
const password2 = document.getElementById("id_password2").value;
|
||||
const password2Input = document.getElementById("id_password2");
|
||||
|
||||
if (password2.length > 0) {
|
||||
if (password1 === password2) {
|
||||
password2Input.classList.remove("border-red-500");
|
||||
password2Input.classList.add("border-green-500");
|
||||
} else {
|
||||
password2Input.classList.remove("border-green-500");
|
||||
password2Input.classList.add("border-red-500");
|
||||
}
|
||||
} else {
|
||||
password2Input.classList.remove("border-green-500", "border-red-500");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 5px;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.header {
|
||||
color: #2563eb;
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
Password Changed Successfully
|
||||
</div>
|
||||
<p>Hi {{ user.username }},</p>
|
||||
<p>This email confirms that your password was recently changed on {{ site_name }}.</p>
|
||||
<p>If you did not make this change, please contact our support team immediately.</p>
|
||||
<div class="footer">
|
||||
<p>Best regards,<br>The {{ site_name }} Team</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
19
backend/templates/accounts/email/password_changed.html
Normal file
19
backend/templates/accounts/email/password_changed.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Password Changed - ThrillWiki</title>
|
||||
</head>
|
||||
<body>
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #2b3a4a;">Password Changed</h1>
|
||||
|
||||
<p>Hi {{ user.username }},</p>
|
||||
|
||||
<p>Your password has been successfully changed on ThrillWiki.</p>
|
||||
|
||||
<p>If you did not make this change, please contact us immediately.</p>
|
||||
|
||||
<p>Best regards,<br>The ThrillWiki Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
47
backend/templates/accounts/email/password_reset.html
Normal file
47
backend/templates/accounts/email/password_reset.html
Normal file
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Reset Your Password</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Reset Your Password</h2>
|
||||
<p>Hello {{ user.get_display_name }},</p>
|
||||
<p>We received a request to reset your password. Click the button below to set a new password:</p>
|
||||
<p>
|
||||
<a href="{{ reset_url }}" class="button">Reset Password</a>
|
||||
</p>
|
||||
<p>This link will expire in 24 hours for security reasons.</p>
|
||||
<p>If you didn't request this password reset, you can safely ignore this email. Your password will remain unchanged.</p>
|
||||
<div class="footer">
|
||||
<p>Best regards,<br>ThrillWiki Team</p>
|
||||
<p>If you're having trouble clicking the button, copy and paste this URL into your browser:<br>
|
||||
{{ reset_url }}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Password Reset Complete</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.success {
|
||||
color: #28a745;
|
||||
font-weight: bold;
|
||||
}
|
||||
.warning {
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Password Reset Complete</h2>
|
||||
<p>Hello {{ user.get_display_name }},</p>
|
||||
<p class="success">Your password has been successfully reset.</p>
|
||||
<p>You can now log in to your account with your new password.</p>
|
||||
<p class="warning">If you did not make this change, please contact us immediately as your account may have been compromised.</p>
|
||||
<div class="footer">
|
||||
<p>Best regards,<br>ThrillWiki Team</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
51
backend/templates/accounts/email/verify_email_change.html
Normal file
51
backend/templates/accounts/email/verify_email_change.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Verify Your New Email Address</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Hello {{ user.username }},</h2>
|
||||
|
||||
<p>We received a request to change your email address on ThrillWiki. To complete this change, please click the button below:</p>
|
||||
|
||||
<a href="{{ verification_url }}" class="button">Verify Email Address</a>
|
||||
|
||||
<p>If the button doesn't work, you can copy and paste this link into your browser:</p>
|
||||
<p>{{ verification_url }}</p>
|
||||
|
||||
<p>This link will expire in 24 hours for security reasons.</p>
|
||||
|
||||
<p>If you did not request this change, please ignore this email or contact support if you have concerns.</p>
|
||||
|
||||
<div class="footer">
|
||||
<p>Best regards,<br>The ThrillWiki Team</p>
|
||||
<p>This is an automated message, please do not reply to this email.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
33
backend/templates/accounts/email_required.html
Normal file
33
backend/templates/accounts/email_required.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends "base/base.html" %}
|
||||
|
||||
{% block title %}Email Required{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-16">
|
||||
<div class="max-w-md mx-auto bg-white rounded-lg shadow-md p-8">
|
||||
<h1 class="text-2xl font-bold mb-6">Email Required</h1>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="mb-6">Please provide your email address to complete the registration process.</p>
|
||||
|
||||
<form method="post" class="space-y-4">
|
||||
{% csrf_token %}
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700">Email Address</label>
|
||||
<input type="email" name="email" id="email" required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-xs focus:border-blue-500 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
170
backend/templates/accounts/profile.html
Normal file
170
backend/templates/accounts/profile.html
Normal file
@@ -0,0 +1,170 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ profile_user.username }}'s Profile - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<!-- Profile Header -->
|
||||
<div class="overflow-hidden bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center">
|
||||
{% if profile_user.profile.avatar %}
|
||||
<img src="{{ profile_user.profile.avatar.url }}"
|
||||
alt="{{ profile_user.username }}"
|
||||
class="object-cover w-24 h-24 rounded-full">
|
||||
{% else %}
|
||||
<div class="flex items-center justify-center w-24 h-24 text-white rounded-full bg-gradient-to-br from-primary to-secondary">
|
||||
{{ profile_user.username.0|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="ml-6">
|
||||
<h1 class="text-2xl font-bold">
|
||||
{{ profile_user.profile.display_name|default:profile_user.username }}
|
||||
</h1>
|
||||
{% if profile_user.profile.pronouns %}
|
||||
<p class="text-gray-600 dark:text-gray-400">{{ profile_user.profile.pronouns }}</p>
|
||||
{% endif %}
|
||||
<div class="mt-2">
|
||||
{% if profile_user.role != 'USER' %}
|
||||
<span class="role-badge role-{{ profile_user.role|lower }}">
|
||||
{{ profile_user.get_role_display }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if profile_user.profile.bio %}
|
||||
<div class="mt-6">
|
||||
<h2 class="mb-2 text-lg font-semibold">About Me</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{ profile_user.profile.bio }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Social Links -->
|
||||
{% if profile_user.profile.twitter or profile_user.profile.instagram or profile_user.profile.youtube or profile_user.profile.discord %}
|
||||
<div class="flex mt-4 space-x-4">
|
||||
{% if profile_user.profile.twitter %}
|
||||
<a href="{{ profile_user.profile.twitter }}" target="_blank" rel="noopener noreferrer"
|
||||
class="text-gray-600 dark:text-gray-400 hover:text-blue-500">
|
||||
<i class="material-icons">link</i> Twitter
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if profile_user.profile.instagram %}
|
||||
<a href="{{ profile_user.profile.instagram }}" target="_blank" rel="noopener noreferrer"
|
||||
class="text-gray-600 dark:text-gray-400 hover:text-blue-500">
|
||||
<i class="material-icons">link</i> Instagram
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if profile_user.profile.youtube %}
|
||||
<a href="{{ profile_user.profile.youtube }}" target="_blank" rel="noopener noreferrer"
|
||||
class="text-gray-600 dark:text-gray-400 hover:text-blue-500">
|
||||
<i class="material-icons">link</i> YouTube
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if profile_user.profile.discord %}
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
<i class="material-icons">discord</i> {{ profile_user.profile.discord }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="grid grid-cols-1 gap-4 mt-6 md:grid-cols-4">
|
||||
<div class="card">
|
||||
<div class="text-center card-body">
|
||||
<div class="stat-value">{{ profile_user.profile.coaster_credits }}</div>
|
||||
<div class="stat-label">Coaster Credits</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="text-center card-body">
|
||||
<div class="stat-value">{{ profile_user.profile.dark_ride_credits }}</div>
|
||||
<div class="stat-label">Dark Ride Credits</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="text-center card-body">
|
||||
<div class="stat-value">{{ profile_user.profile.flat_ride_credits }}</div>
|
||||
<div class="stat-label">Flat Ride Credits</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="text-center card-body">
|
||||
<div class="stat-value">{{ profile_user.profile.water_ride_credits }}</div>
|
||||
<div class="stat-label">Water Ride Credits</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="grid grid-cols-1 gap-6 mt-6 md:grid-cols-2">
|
||||
<!-- Recent Reviews -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="text-xl font-semibold">Recent Reviews</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for review in park_reviews %}
|
||||
<div class="mb-4 last:mb-0">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium">{{ review.title }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ review.park.name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="mr-1 text-yellow-400">★</span>
|
||||
<span>{{ review.rating }}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for review in ride_reviews %}
|
||||
<div class="mb-4 last:mb-0">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium">{{ review.title }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ review.ride.name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="mr-1 text-yellow-400">★</span>
|
||||
<span>{{ review.rating }}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not park_reviews and not ride_reviews %}
|
||||
<p class="text-gray-500 dark:text-gray-400">No reviews yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Lists -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="text-xl font-semibold">Top Lists</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for list in top_lists %}
|
||||
<div class="mb-4 last:mb-0">
|
||||
<h3 class="font-medium">{{ list.title }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ list.get_category_display }}
|
||||
</p>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-gray-500 dark:text-gray-400">No top lists created yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
109
backend/templates/accounts/settings.html
Normal file
109
backend/templates/accounts/settings.html
Normal file
@@ -0,0 +1,109 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Settings - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto max-w-[800px]">
|
||||
<h1 class="mb-4 text-2xl font-bold">Settings</h1>
|
||||
|
||||
<div class="p-6 overflow-hidden bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="update_profile">
|
||||
|
||||
<h2 class="mb-4 text-lg font-semibold">Update Profile</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="display_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Display Name</label>
|
||||
<input type="text" name="display_name" id="display_name" value="{{ user.profile.display_name }}" class="block w-full mt-1 border-gray-300 rounded-md shadow-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="avatar" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Avatar</label>
|
||||
<input type="file" name="avatar" id="avatar" class="block w-full mt-1 text-gray-900 dark:text-gray-300">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="px-4 py-2 text-white bg-blue-500 rounded-md">Update Profile</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="p-6 mt-6 overflow-hidden bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="change_email">
|
||||
|
||||
<h2 class="mb-4 text-lg font-semibold">Change Email</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="new_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">New Email</label>
|
||||
<input type="email" name="new_email" id="new_email" class="block w-full mt-1 border-gray-300 rounded-md shadow-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="px-4 py-2 text-white bg-blue-500 rounded-md">Change Email</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="p-6 mt-6 overflow-hidden bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<form method="POST" x-data="{
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
passwordsMatch() { return this.newPassword === this.confirmPassword },
|
||||
isValidPassword() {
|
||||
return this.newPassword.length >= 8 &&
|
||||
/[A-Z]/.test(this.newPassword) &&
|
||||
/[a-z]/.test(this.newPassword) &&
|
||||
/[0-9]/.test(this.newPassword);
|
||||
}
|
||||
}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="change_password">
|
||||
|
||||
<h2 class="mb-4 text-lg font-semibold">Change Password</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="old_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Current Password</label>
|
||||
<input type="password" name="old_password" id="old_password" required class="block w-full mt-1 border-gray-300 rounded-md shadow-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="new_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new_password"
|
||||
id="new_password"
|
||||
x-model="newPassword"
|
||||
required
|
||||
class="block w-full mt-1 border-gray-300 rounded-md shadow-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400" x-show="newPassword && !isValidPassword()">
|
||||
Password must be at least 8 characters and contain uppercase, lowercase, and numbers
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="confirm_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
id="confirm_password"
|
||||
x-model="confirmPassword"
|
||||
required
|
||||
class="block w-full mt-1 border-gray-300 rounded-md shadow-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<div class="mt-1 text-sm text-red-500" x-show="confirmPassword && !passwordsMatch()">
|
||||
Passwords do not match
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-white bg-blue-500 rounded-md disabled:opacity-50"
|
||||
x-bind:disabled="!passwordsMatch() || !isValidPassword()"
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
22
backend/templates/accounts/turnstile_widget.html
Normal file
22
backend/templates/accounts/turnstile_widget.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<script
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
|
||||
async
|
||||
defer
|
||||
></script>
|
||||
<div class="turnstile">
|
||||
<div
|
||||
id="turnstile-widget"
|
||||
class="cf-turnstile"
|
||||
data-sitekey="{{ site_key }}"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Apply theme to the Turnstile widget based on the retrieved theme
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const turnstileWidget = document.getElementById("turnstile-widget");
|
||||
if (turnstileWidget) {
|
||||
turnstileWidget.setAttribute("data-theme", theme);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
1
backend/templates/accounts/turnstile_widget_empty.html
Normal file
1
backend/templates/accounts/turnstile_widget_empty.html
Normal file
@@ -0,0 +1 @@
|
||||
<!-- Empty template when DEBUG is True -->
|
||||
324
backend/templates/base/base.html
Normal file
324
backend/templates/base/base.html
Normal file
@@ -0,0 +1,324 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="csrf-token" content="{{ csrf_token }}" />
|
||||
<title>{% block title %}ThrillWiki{% endblock %}</title>
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- Prevent flash of wrong theme -->
|
||||
<script>
|
||||
let theme = localStorage.getItem("theme");
|
||||
if (!theme) {
|
||||
theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
localStorage.setItem("theme", theme);
|
||||
}
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="{% static 'js/alpine.min.js' %}"></script>
|
||||
|
||||
<!-- Location Autocomplete -->
|
||||
<script src="{% static 'js/location-autocomplete.js' %}"></script>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'css/alerts.css' %}" rel="stylesheet" />
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
||||
/>
|
||||
|
||||
<style>
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-top: 0.5rem;
|
||||
width: 12rem;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
z-index: 50;
|
||||
overflow: hidden;
|
||||
}
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
.htmx-request .htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
.htmx-request.htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body
|
||||
class="flex flex-col min-h-screen text-gray-900 bg-gradient-to-br from-white via-blue-50 to-indigo-50 dark:from-gray-950 dark:via-indigo-950 dark:to-purple-950 dark:text-white"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header
|
||||
class="sticky top-0 z-40 border-b shadow-lg bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50"
|
||||
>
|
||||
<nav class="container mx-auto nav-container">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center">
|
||||
<a
|
||||
href="{% url 'home' %}"
|
||||
class="font-bold text-transparent transition-transform site-logo bg-gradient-to-r from-primary to-secondary bg-clip-text hover:scale-105"
|
||||
>
|
||||
ThrillWiki
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links (Always Visible) -->
|
||||
<div class="flex items-center space-x-2 sm:space-x-4">
|
||||
<a href="{% url 'parks:park_list' %}" class="nav-link">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
<span>Parks</span>
|
||||
</a>
|
||||
<a href="{% url 'rides:global_ride_list' %}" class="nav-link">
|
||||
<i class="fas fa-rocket"></i>
|
||||
<span>Rides</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="flex-1 hidden max-w-md mx-8 lg:flex">
|
||||
<form action="{% url 'search:search' %}" method="get" class="w-full">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
placeholder="Search parks and rides..."
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Right Side Menu -->
|
||||
<div class="flex items-center space-x-2 sm:space-x-6">
|
||||
<!-- Theme Toggle -->
|
||||
<label for="theme-toggle" class="cursor-pointer">
|
||||
<input type="checkbox" id="theme-toggle" class="hidden" />
|
||||
<div
|
||||
class="inline-flex items-center justify-center p-2 text-gray-500 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary theme-toggle-btn"
|
||||
role="button"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
<i class="text-xl fas"></i>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- User Menu -->
|
||||
{% if user.is_authenticated %} {% if has_moderation_access %}
|
||||
<a href="{% url 'moderation:dashboard' %}" class="nav-link">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<span>Moderation</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div
|
||||
class="relative"
|
||||
x-data="{ open: false }"
|
||||
@click.outside="open = false"
|
||||
>
|
||||
<!-- Profile Picture Button -->
|
||||
{% if user.profile.avatar %}
|
||||
<img
|
||||
@click="open = !open"
|
||||
src="{{ user.profile.avatar.url }}"
|
||||
alt="{{ user.username }}"
|
||||
class="w-8 h-8 transition-transform rounded-full cursor-pointer ring-2 ring-primary/20 hover:scale-105"
|
||||
/>
|
||||
{% else %}
|
||||
<div
|
||||
@click="open = !open"
|
||||
class="flex items-center justify-center w-8 h-8 text-white transition-transform rounded-full cursor-pointer bg-gradient-to-br from-primary to-secondary hover:scale-105"
|
||||
>
|
||||
{{ user.username.0|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Dropdown Menu -->
|
||||
<div
|
||||
x-cloak
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="bg-white dropdown-menu dark:bg-gray-800"
|
||||
>
|
||||
<a href="{% url 'profile' user.username %}" class="menu-item">
|
||||
<i class="w-5 fas fa-user"></i>
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
<a href="{% url 'settings' %}" class="menu-item">
|
||||
<i class="w-5 fas fa-cog"></i>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
{% if has_admin_access %}
|
||||
<a href="{% url 'admin:index' %}" class="menu-item">
|
||||
<i class="w-5 fas fa-shield-alt"></i>
|
||||
<span>Admin</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'account_logout' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="w-full menu-item">
|
||||
<i class="w-5 fas fa-sign-out-alt"></i>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Generic Profile Icon for Unauthenticated Users -->
|
||||
<div
|
||||
class="relative"
|
||||
x-data="{ open: false }"
|
||||
@click.outside="open = false"
|
||||
>
|
||||
<div
|
||||
@click="open = !open"
|
||||
class="flex items-center justify-center w-8 h-8 text-gray-500 transition-transform rounded-full cursor-pointer hover:text-primary dark:text-gray-400 dark:hover:text-primary hover:scale-105"
|
||||
>
|
||||
<i class="text-xl fas fa-user"></i>
|
||||
</div>
|
||||
|
||||
<!-- Auth Menu -->
|
||||
<div
|
||||
x-cloak
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="bg-white dropdown-menu dark:bg-gray-800"
|
||||
>
|
||||
<div
|
||||
hx-get="{% url 'account_login' %}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
class="cursor-pointer menu-item"
|
||||
>
|
||||
<i class="w-5 fas fa-sign-in-alt"></i>
|
||||
<span>Login</span>
|
||||
</div>
|
||||
<div
|
||||
hx-get="{% url 'account_signup' %}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
class="cursor-pointer menu-item"
|
||||
>
|
||||
<i class="w-5 fas fa-user-plus"></i>
|
||||
<span>Register</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
id="mobileMenuBtn"
|
||||
class="p-2 text-gray-500 rounded-lg lg:hidden hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-400"
|
||||
aria-label="Toggle mobile menu"
|
||||
>
|
||||
<i class="text-2xl fas fa-bars"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div id="mobileMenu">
|
||||
<div class="space-y-4">
|
||||
<!-- Search (Mobile) -->
|
||||
<form action="{% url 'search:search' %}" method="get" class="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
placeholder="Search parks and rides..."
|
||||
class="form-input"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% if messages %}
|
||||
<div class="fixed top-0 right-0 z-50 p-4 space-y-4">
|
||||
{% for message in messages %}
|
||||
<div
|
||||
class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container flex-grow px-6 py-8 mx-auto">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer
|
||||
class="mt-auto border-t bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50"
|
||||
>
|
||||
<div class="container px-6 py-6 mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-gray-600 dark:text-gray-400">
|
||||
<p>© {% now "Y" %} ThrillWiki. All rights reserved.</p>
|
||||
</div>
|
||||
<div class="space-x-4">
|
||||
<a
|
||||
href="{% url 'terms' %}"
|
||||
class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary"
|
||||
>Terms</a
|
||||
>
|
||||
<a
|
||||
href="{% url 'privacy' %}"
|
||||
class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary"
|
||||
>Privacy</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Custom JavaScript -->
|
||||
<script src="{% static 'js/main.js' %}"></script>
|
||||
<script src="{% static 'js/alerts.js' %}"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
80
backend/templates/components/card.html
Normal file
80
backend/templates/components/card.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{% comment %}
|
||||
Reusable card component for consistent styling across the application.
|
||||
Usage: {% include 'components/card.html' with title="Card Title" content="Card content" %}
|
||||
{% endcomment %}
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 overflow-hidden {{ extra_classes|default:'' }}">
|
||||
{% if image_url %}
|
||||
<div class="aspect-w-16 aspect-h-9">
|
||||
<img
|
||||
src="{{ image_url }}"
|
||||
alt="{{ image_alt|default:title }}"
|
||||
class="w-full h-48 object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="p-6">
|
||||
{% if title %}
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">
|
||||
{% if link_url %}
|
||||
<a href="{{ link_url }}" class="hover:text-blue-600 transition-colors">
|
||||
{{ title }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ title }}
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% endif %}
|
||||
|
||||
{% if subtitle %}
|
||||
<p class="text-sm text-gray-500 mb-3">{{ subtitle }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if content %}
|
||||
<div class="text-gray-700 mb-4">
|
||||
{{ content|truncatewords:30 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if tags %}
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{% for tag in tags %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ tag }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if stats %}
|
||||
<div class="flex items-center justify-between text-sm text-gray-500 mb-4">
|
||||
{% for stat in stats %}
|
||||
<div class="flex items-center">
|
||||
{% if stat.icon %}
|
||||
<i class="{{ stat.icon }} mr-1"></i>
|
||||
{% endif %}
|
||||
<span>{{ stat.label }}: {{ stat.value }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if actions %}
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
{% for action in actions %}
|
||||
<a
|
||||
href="{{ action.url }}"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
>
|
||||
{% if action.icon %}
|
||||
<i class="{{ action.icon }} mr-1"></i>
|
||||
{% endif %}
|
||||
{{ action.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
93
backend/templates/components/pagination.html
Normal file
93
backend/templates/components/pagination.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{% comment %}
|
||||
Reusable pagination component with accessibility and responsive design.
|
||||
Usage: {% include 'components/pagination.html' with page_obj=page_obj %}
|
||||
{% endcomment %}
|
||||
|
||||
{% if page_obj.has_other_pages %}
|
||||
<nav class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6" aria-label="Pagination">
|
||||
<div class="hidden sm:block">
|
||||
<p class="text-sm text-gray-700">
|
||||
Showing
|
||||
<span class="font-medium">{{ page_obj.start_index }}</span>
|
||||
to
|
||||
<span class="font-medium">{{ page_obj.end_index }}</span>
|
||||
of
|
||||
<span class="font-medium">{{ page_obj.paginator.count }}</span>
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex justify-between sm:justify-end">
|
||||
{% if page_obj.has_previous %}
|
||||
<a
|
||||
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.previous_page_number }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Previous
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-400 bg-gray-100 cursor-not-allowed">
|
||||
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Previous
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Page numbers for larger screens -->
|
||||
<div class="hidden md:flex">
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if num == page_obj.number %}
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-blue-500 bg-blue-50 text-sm font-medium text-blue-600 mx-1">
|
||||
{{ num }}
|
||||
</span>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<a
|
||||
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 mx-1 transition-colors"
|
||||
aria-label="Go to page {{ num }}"
|
||||
>
|
||||
{{ num }}
|
||||
</a>
|
||||
{% elif num == 1 or num == page_obj.paginator.num_pages %}
|
||||
<a
|
||||
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 mx-1 transition-colors"
|
||||
aria-label="Go to page {{ num }}"
|
||||
>
|
||||
{{ num }}
|
||||
</a>
|
||||
{% elif num == page_obj.number|add:'-4' or num == page_obj.number|add:'4' %}
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-500 mx-1">
|
||||
...
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a
|
||||
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.next_page_number }}"
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
Next
|
||||
<svg class="ml-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-400 bg-gray-100 cursor-not-allowed">
|
||||
Next
|
||||
<svg class="ml-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
142
backend/templates/components/search_form.html
Normal file
142
backend/templates/components/search_form.html
Normal file
@@ -0,0 +1,142 @@
|
||||
{% comment %}
|
||||
Reusable search form component with filtering capabilities.
|
||||
Usage: {% include 'components/search_form.html' with placeholder="Search parks..." filters=filter_options %}
|
||||
{% endcomment %}
|
||||
|
||||
<form method="get" class="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Search Input -->
|
||||
<div class="col-span-1 md:col-span-2">
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
id="search"
|
||||
value="{{ request.GET.search }}"
|
||||
placeholder="{{ placeholder|default:'Search...' }}"
|
||||
class="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if filters %}
|
||||
{% for filter in filters %}
|
||||
<div>
|
||||
<label for="{{ filter.name }}" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{{ filter.label }}
|
||||
</label>
|
||||
|
||||
{% if filter.type == 'select' %}
|
||||
<select
|
||||
name="{{ filter.name }}"
|
||||
id="{{ filter.name }}"
|
||||
class="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">All {{ filter.label }}</option>
|
||||
{% for option in filter.options %}
|
||||
<option
|
||||
value="{{ option.value }}"
|
||||
{% if request.GET|get_item:filter.name == option.value %}selected{% endif %}
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
{% elif filter.type == 'checkbox' %}
|
||||
<div class="space-y-2">
|
||||
{% for option in filter.options %}
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="{{ filter.name }}"
|
||||
value="{{ option.value }}"
|
||||
{% if option.value in request.GET|getlist:filter.name %}checked{% endif %}
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
>
|
||||
<span class="ml-2 text-sm text-gray-700">{{ option.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% elif filter.type == 'range' %}
|
||||
<div class="flex space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
name="{{ filter.name }}_min"
|
||||
value="{{ request.GET|get_item:filter.name_min }}"
|
||||
placeholder="Min"
|
||||
class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
name="{{ filter.name }}_max"
|
||||
value="{{ request.GET|get_item:filter.name_max }}"
|
||||
placeholder="Max"
|
||||
class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
>
|
||||
<svg class="-ml-1 mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Search
|
||||
</button>
|
||||
|
||||
{% if request.GET.urlencode %}
|
||||
<a
|
||||
href="{{ request.path }}"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
>
|
||||
<svg class="-ml-1 mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Clear
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if show_sort %}
|
||||
<div class="flex items-center space-x-2">
|
||||
<label for="ordering" class="text-sm font-medium text-gray-700">Sort by:</label>
|
||||
<select
|
||||
name="ordering"
|
||||
id="ordering"
|
||||
onchange="this.form.submit()"
|
||||
class="block px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
{% for option in sort_options|default:"name,Name (A-Z);-name,Name (Z-A);created_at,Newest First;-created_at,Oldest First" %}
|
||||
{% with option_parts=option|split:"," %}
|
||||
<option
|
||||
value="{{ option_parts.0 }}"
|
||||
{% if request.GET.ordering == option_parts.0 %}selected{% endif %}
|
||||
>
|
||||
{{ option_parts.1 }}
|
||||
</option>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
17
backend/templates/components/status_badge.html
Normal file
17
backend/templates/components/status_badge.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% comment %}
|
||||
Reusable status badge component with consistent styling.
|
||||
Usage: {% include 'components/status_badge.html' with status="OPERATING" %}
|
||||
{% endcomment %}
|
||||
|
||||
{% load park_tags %}
|
||||
|
||||
{% with status_config=status|get_status_config %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ status_config.classes }}">
|
||||
{% if status_config.icon %}
|
||||
<svg class="-ml-0.5 mr-1.5 h-2 w-2" fill="currentColor" viewBox="0 0 8 8">
|
||||
<circle cx="4" cy="4" r="3" />
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ status_config.label }}
|
||||
</span>
|
||||
{% endwith %}
|
||||
909
backend/templates/core/search/components/filter_form.html
Normal file
909
backend/templates/core/search/components/filter_form.html
Normal file
@@ -0,0 +1,909 @@
|
||||
{# Enhanced filter form with modern UI/UX design - timestamp: 2025-08-21 #}
|
||||
{% load static %}
|
||||
{% load widget_tweaks %}
|
||||
|
||||
<!-- Mobile Filter Toggle Button -->
|
||||
<button class="mobile-nav-toggle md:hidden fixed bottom-6 right-6 z-50 w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg transition-all duration-300 transform hover:scale-110 focus:outline-none focus:ring-4 focus:ring-blue-500/50"
|
||||
onclick="toggleMobileFilters()"
|
||||
aria-label="Toggle filters"
|
||||
data-tooltip="Show/Hide Filters">
|
||||
<svg class="w-6 h-6 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="filter-container" x-data="filterManager()" x-init="init()">
|
||||
{# Quick Filter Presets - Enhanced Design #}
|
||||
<div class="mb-8 bg-gradient-to-br from-white via-gray-50 to-white dark:from-gray-800 dark:via-gray-800 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 p-6 backdrop-blur-sm">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-2 h-8 bg-gradient-to-b from-blue-500 to-purple-600 rounded-full"></div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Quick Filters</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Find parks instantly with preset filters</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Live filtering</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('disney')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('disney') ? 'bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-lg ring-2 ring-blue-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Show Disney Parks">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:rotate-12" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-yellow-400 rounded-full animate-ping" x-show="isQuickFilterActive('disney')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">Disney Parks</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.disney" x-text="`${quickFilterCounts.disney} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-blue-400/10 to-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('coasters')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('coasters') ? 'bg-gradient-to-br from-green-500 to-green-600 text-white shadow-lg ring-2 ring-green-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-green-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Parks with Roller Coasters">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.94-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
</svg>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-red-400 rounded-full animate-ping" x-show="isQuickFilterActive('coasters')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">With Coasters</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.coasters" x-text="`${quickFilterCounts.coasters} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-green-400/10 to-emerald-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('top_rated')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('top_rated') ? 'bg-gradient-to-br from-yellow-500 to-yellow-600 text-white shadow-lg ring-2 ring-yellow-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-yellow-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Highly Rated Parks (4+ stars)">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:rotate-12" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-yellow-400 rounded-full animate-ping" x-show="isQuickFilterActive('top_rated')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">Top Rated</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.top_rated" x-text="`${quickFilterCounts.top_rated} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-yellow-400/10 to-orange-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
@click="applyQuickFilter('major_parks')"
|
||||
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
|
||||
:class="isQuickFilterActive('major_parks') ? 'bg-gradient-to-br from-purple-500 to-purple-600 text-white shadow-lg ring-2 ring-purple-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-purple-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
|
||||
title="Major Theme Parks (10+ rides)">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="relative">
|
||||
<svg class="w-8 h-8 transition-transform duration-300 group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-purple-400 rounded-full animate-ping" x-show="isQuickFilterActive('major_parks')"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-sm">Major Parks</div>
|
||||
<div class="text-xs opacity-75" x-show="quickFilterCounts.major_parks" x-text="`${quickFilterCounts.major_parks} parks`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-purple-400/10 to-pink-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Mobile Filter Toggle - Enhanced Design #}
|
||||
<div class="lg:hidden mb-6">
|
||||
<button type="button"
|
||||
@click="showMobileFilters = !showMobileFilters"
|
||||
class="w-full group relative overflow-hidden rounded-xl p-4 bg-gradient-to-r from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 border border-gray-200/50 dark:border-gray-700/50 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-2 bg-blue-500/10 dark:bg-blue-400/10 rounded-lg">
|
||||
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-lg font-bold text-gray-900 dark:text-white">Filters</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Customize your search</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 rounded-full">
|
||||
<span class="text-xs font-semibold text-blue-800 dark:text-blue-300" x-text="activeFilterCount + ' active'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-6 h-6 text-gray-400 transition-transform duration-300" :class="showMobileFilters ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Main Filter Form - Enhanced Design #}
|
||||
<form hx-get="{% url 'parks:search_parks' %}"
|
||||
hx-target="#search-results"
|
||||
hx-trigger="change delay:300ms, submit"
|
||||
hx-indicator="#loading-indicator"
|
||||
hx-push-url="true"
|
||||
hx-swap="innerHTML transition:true"
|
||||
class="space-y-8"
|
||||
:class="showMobileFilters || window.innerWidth >= 1024 ? 'block' : 'hidden'"
|
||||
x-show.transition="showMobileFilters || window.innerWidth >= 1024">
|
||||
|
||||
{# Search Input Section - Enhanced Design #}
|
||||
<div class="bg-gradient-to-br from-white via-gray-50 to-white dark:from-gray-800 dark:via-gray-850 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 p-6 backdrop-blur-sm">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<div class="w-2 h-8 bg-gradient-to-b from-green-500 to-blue-600 rounded-full"></div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Search & Discover</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Find parks by name, location, or features</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/20 to-purple-500/20 rounded-xl blur-xl opacity-0 group-focus-within:opacity-100 transition-opacity duration-500"></div>
|
||||
<div class="relative">
|
||||
<label for="search" class="sr-only">Search Parks</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
id="search"
|
||||
name="search"
|
||||
x-model="searchQuery"
|
||||
placeholder="Search by park name, location, or features..."
|
||||
class="w-full px-6 py-4 pl-14 pr-12 text-lg border-2 border-gray-200 dark:border-gray-600 rounded-xl bg-white/80 dark:bg-gray-800/80 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-300 ease-out backdrop-blur-sm hover:border-gray-300 dark:hover:border-gray-500 group-focus-within:shadow-xl">
|
||||
|
||||
{# Search Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<div class="p-1 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<svg class="h-6 w-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Clear Button #}
|
||||
<div class="absolute inset-y-0 right-0 pr-4 flex items-center" x-show="searchQuery" x-transition>
|
||||
<button type="button"
|
||||
@click="searchQuery = ''"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
title="Clear search">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Loading Indicator #}
|
||||
<div class="absolute inset-y-0 right-0 pr-4 flex items-center" x-show="isLoading" x-transition>
|
||||
<div class="animate-spin h-5 w-5 text-blue-500">
|
||||
<svg fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Search Suggestions/Hints #}
|
||||
<div class="mt-3 flex flex-wrap gap-2" x-show="!searchQuery">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Popular searches:</div>
|
||||
<button type="button" @click="searchQuery = 'Disney'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Disney</button>
|
||||
<button type="button" @click="searchQuery = 'roller coaster'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Roller Coaster</button>
|
||||
<button type="button" @click="searchQuery = 'California'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">California</button>
|
||||
<button type="button" @click="searchQuery = 'water park'" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">Water Park</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Loading Indicator #}
|
||||
<div id="loading-indicator" class="htmx-indicator fixed top-4 right-4 z-50">
|
||||
<div class="bg-gradient-to-r from-blue-600 to-blue-700 text-white px-6 py-3 rounded-xl shadow-2xl border border-blue-500/20 backdrop-blur-sm">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="animate-spin h-5 w-5 text-white">
|
||||
<svg fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-medium">Updating results...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Active Filters Summary - Enhanced #}
|
||||
<div x-show="activeFilterCount > 0"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform scale-95 translate-y-4"
|
||||
x-transition:enter-end="opacity-100 transform scale-100 translate-y-0"
|
||||
class="bg-gradient-to-r from-blue-50 via-indigo-50 to-purple-50 dark:from-blue-900/20 dark:via-indigo-900/20 dark:to-purple-900/20 rounded-xl p-5 shadow-lg border border-blue-200/50 dark:border-blue-700/50 backdrop-blur-sm">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-800/30 rounded-lg">
|
||||
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-blue-900 dark:text-blue-100">Active Filters</h3>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300" x-text="`${activeFilterCount} filter${activeFilterCount !== 1 ? 's' : ''} applied`"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
@click="clearAllFilters()"
|
||||
class="px-4 py-2 text-sm font-semibold text-blue-700 dark:text-blue-300 bg-white/80 dark:bg-gray-800/80 hover:bg-blue-100 dark:hover:bg-blue-800/50 rounded-lg border border-blue-200 dark:border-blue-600 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 hover:shadow-md"
|
||||
aria-label="Clear all filters">
|
||||
Clear All Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Filter Groups #}
|
||||
<div class="bg-gradient-to-br from-white via-gray-50 to-white dark:from-gray-800 dark:via-gray-850 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 backdrop-blur-sm">
|
||||
<div class="p-6" x-data="{ expanded: true }">
|
||||
{# Enhanced Group Header #}
|
||||
<button type="button"
|
||||
@click="expanded = !expanded"
|
||||
class="w-full group flex justify-between items-center text-left p-3 -m-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200"
|
||||
:aria-expanded="expanded"
|
||||
aria-controls="filter-group-1">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-2 h-8 bg-gradient-to-b from-purple-500 to-pink-600 rounded-full"></div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">Advanced Filters</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Refine your search with detailed criteria</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-full">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">Advanced</span>
|
||||
</div>
|
||||
<svg class="w-6 h-6 text-gray-400 group-hover:text-blue-500 dark:group-hover:text-blue-400 transform transition-all duration-300"
|
||||
:class="{'rotate-180': !expanded}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{# Enhanced Group Content #}
|
||||
<div id="filter-group-1"
|
||||
class="mt-6 space-y-6"
|
||||
x-show="expanded"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 max-h-0"
|
||||
x-transition:enter-end="opacity-100 max-h-screen"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 max-h-screen"
|
||||
x-transition:leave-end="opacity-0 max-h-0">
|
||||
{% for field in filter.form %}
|
||||
<div class="filter-field group">
|
||||
<label for="{{ field.id_for_label }}"
|
||||
class="flex items-center justify-between text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
{# Enhanced Field Icons Based on Type #}
|
||||
<div class="p-2 bg-gradient-to-br from-blue-50 to-purple-50 dark:from-blue-900/30 dark:to-purple-900/30 rounded-lg border border-blue-200/50 dark:border-blue-700/50">
|
||||
{% if field.name == 'search' or field.field.widget.input_type == 'search' %}
|
||||
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
{% elif 'location' in field.name or 'city' in field.name or 'state' in field.name or 'country' in field.name %}
|
||||
<svg class="w-4 h-4 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
{% elif 'date' in field.name or field.field.widget.input_type == 'date' %}
|
||||
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{% elif 'operator' in field.name or 'company' in field.name %}
|
||||
<svg class="w-4 h-4 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
{% elif 'rating' in field.name or 'star' in field.name %}
|
||||
<svg class="w-4 h-4 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
|
||||
</svg>
|
||||
{% elif 'count' in field.name or field.field.widget.input_type == 'number' %}
|
||||
<svg class="w-4 h-4 text-teal-600 dark:text-teal-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
{% elif field.field.widget.input_type == 'checkbox' %}
|
||||
<svg class="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{% elif field.field.widget.input_type == 'select' %}
|
||||
<svg class="w-4 h-4 text-pink-600 dark:text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span>{{ field.label }}</span>
|
||||
{% if field.help_text %}
|
||||
<button type="button"
|
||||
class="text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400 focus:outline-none transition-colors duration-200"
|
||||
@click="$tooltip('{{ field.help_text|escapejs }}', $event)"
|
||||
aria-label="Help for {{ field.label }}">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Field Clear Button #}
|
||||
<button type="button"
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 dark:hover:text-red-400 focus:outline-none transition-all duration-200 text-xs"
|
||||
@click="clearField('{{ field.id_for_label }}')"
|
||||
aria-label="Clear {{ field.label }}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
{% if field.field.widget.input_type == 'text' or field.field.widget.input_type == 'search' %}
|
||||
<div class="relative group/input">
|
||||
<input type="{{ field.field.widget.input_type }}"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
value="{{ field.value|default_if_none:'' }}"
|
||||
placeholder="{{ field.field.widget.attrs.placeholder|default:'' }}"
|
||||
class="block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md">
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
{% if field.name == 'search' or field.field.widget.input_type == 'search' %}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
{% elif 'location' in field.name or 'city' in field.name %}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'number' %}
|
||||
<div class="relative group/input">
|
||||
<input type="number"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
value="{{ field.value|default_if_none:'' }}"
|
||||
min="{{ field.field.widget.attrs.min|default:'' }}"
|
||||
max="{{ field.field.widget.attrs.max|default:'' }}"
|
||||
placeholder="{{ field.field.widget.attrs.placeholder|default:'Enter number...' }}"
|
||||
class="block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md">
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'checkbox' %}
|
||||
<div class="relative group/checkbox">
|
||||
<div class="flex items-center p-4 bg-gradient-to-r from-gray-50 to-white dark:from-gray-700/50 dark:to-gray-800/50 rounded-lg border-2 border-gray-200 dark:border-gray-600 hover:border-blue-300 dark:hover:border-blue-500 hover:bg-gradient-to-r hover:from-blue-50 hover:to-purple-50 dark:hover:from-blue-900/20 dark:hover:to-purple-900/20 transition-all duration-200 cursor-pointer">
|
||||
<div class="relative flex items-center">
|
||||
<input type="checkbox"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
{% if field.value %}checked{% endif %}
|
||||
class="h-5 w-5 text-blue-600 focus:ring-2 focus:ring-blue-500/20 border-2 border-gray-300 dark:border-gray-500 dark:bg-gray-600 rounded transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
|
||||
<div class="absolute inset-0 rounded bg-blue-500 opacity-0 group-hover/checkbox:opacity-10 transition-opacity duration-200"></div>
|
||||
</div>
|
||||
<label for="{{ field.id_for_label }}" class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
{# Checkbox Icon #}
|
||||
<div class="ml-auto text-gray-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'select' %}
|
||||
<div class="relative group/select">
|
||||
<select name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
class="block w-full pl-12 pr-10 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/select:shadow-md appearance-none">
|
||||
{% for choice in field.field.choices %}
|
||||
<option value="{{ choice.0 }}" {% if choice.0 == field.value %}selected{% endif %}>{{ choice.1 }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
{% if 'operator' in field.name %}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Right Dropdown Arrow #}
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<svg class="h-5 w-5 text-gray-400 group-hover/select:text-gray-600 dark:group-hover/select:text-gray-300 transition-colors duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget.input_type == 'date' %}
|
||||
<div class="relative group/input">
|
||||
<input type="date"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
value="{{ field.value|default_if_none:'' }}"
|
||||
class="block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md">
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="relative group/input">
|
||||
{{ field|add_class:"block w-full pl-12 pr-4 py-3 text-sm border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 group-hover/input:shadow-md" }}
|
||||
{# Left Icon #}
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Mobile Apply Button #}
|
||||
<div class="lg:hidden">
|
||||
<button type="submit"
|
||||
class="w-full group relative overflow-hidden bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white px-6 py-4 rounded-xl font-semibold text-lg shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-300 transform hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
:disabled="isLoading">
|
||||
<div class="relative z-10 flex items-center justify-center">
|
||||
<span x-show="!isLoading" class="flex items-center space-x-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
||||
</svg>
|
||||
<span>Apply Filters</span>
|
||||
</span>
|
||||
<span x-show="isLoading" class="flex items-center space-x-3">
|
||||
<div class="animate-spin h-5 w-5 text-white">
|
||||
<svg fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-medium">Applying Filters...</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Enhanced Scripts #}
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('filterManager', () => ({
|
||||
mobileFiltersOpen: false,
|
||||
activeFilterCount: 0,
|
||||
isLoading: false,
|
||||
quickFilterCounts: {
|
||||
disney: 0,
|
||||
coasters: 0,
|
||||
top_rated: 0,
|
||||
major_parks: 0
|
||||
},
|
||||
|
||||
init() {
|
||||
this.updateActiveFilterCount();
|
||||
this.loadQuickFilterCounts();
|
||||
|
||||
// Listen for HTMX events
|
||||
document.addEventListener('htmx:beforeRequest', () => {
|
||||
this.isLoading = true;
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', () => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:responseError', () => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
toggleMobileFilters() {
|
||||
this.mobileFiltersOpen = !this.mobileFiltersOpen;
|
||||
},
|
||||
|
||||
updateActiveFilterCount() {
|
||||
// Count active filters from form inputs
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
let count = 0;
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (input.name && input.value && input.value !== '' && input.value !== 'all') {
|
||||
// Skip hidden fields and empty values
|
||||
if (input.type !== 'hidden' && input.value !== '0') {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.activeFilterCount = count;
|
||||
},
|
||||
|
||||
clearAllFilters() {
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
// Clear all form inputs
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = false;
|
||||
} else if (input.type !== 'hidden') {
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger form submission
|
||||
this.activeFilterCount = 0;
|
||||
htmx.trigger(form, 'submit');
|
||||
},
|
||||
|
||||
applyQuickFilter(filterType) {
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
// Clear existing filters first
|
||||
this.clearAllFilters();
|
||||
|
||||
// Apply specific filter based on type
|
||||
switch(filterType) {
|
||||
case 'disney':
|
||||
const parkTypeField = form.querySelector('[name="park_type"]');
|
||||
if (parkTypeField) parkTypeField.value = 'disney';
|
||||
break;
|
||||
|
||||
case 'coasters':
|
||||
const coastersField = form.querySelector('[name="has_coasters"]');
|
||||
if (coastersField) coastersField.checked = true;
|
||||
break;
|
||||
|
||||
case 'top_rated':
|
||||
const ratingField = form.querySelector('[name="min_rating"]');
|
||||
if (ratingField) ratingField.value = '4';
|
||||
break;
|
||||
|
||||
case 'major_parks':
|
||||
const bigParksField = form.querySelector('[name="big_parks_only"]');
|
||||
if (bigParksField) bigParksField.checked = true;
|
||||
break;
|
||||
}
|
||||
|
||||
this.updateActiveFilterCount();
|
||||
htmx.trigger(form, 'submit');
|
||||
},
|
||||
|
||||
isQuickFilterActive(filterType) {
|
||||
const form = this.$el.querySelector('form');
|
||||
if (!form) return false;
|
||||
|
||||
switch(filterType) {
|
||||
case 'disney':
|
||||
const parkTypeField = form.querySelector('[name="park_type"]');
|
||||
return parkTypeField && parkTypeField.value === 'disney';
|
||||
|
||||
case 'coasters':
|
||||
const coastersField = form.querySelector('[name="has_coasters"]');
|
||||
return coastersField && coastersField.checked;
|
||||
|
||||
case 'top_rated':
|
||||
const ratingField = form.querySelector('[name="min_rating"]');
|
||||
return ratingField && ratingField.value === '4';
|
||||
|
||||
case 'major_parks':
|
||||
const bigParksField = form.querySelector('[name="big_parks_only"]');
|
||||
return bigParksField && bigParksField.checked;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
loadQuickFilterCounts() {
|
||||
// This would typically fetch from an API endpoint
|
||||
// For now, set some placeholder values
|
||||
this.quickFilterCounts = {
|
||||
disney: 12,
|
||||
coasters: 156,
|
||||
top_rated: 89,
|
||||
major_parks: 78
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
// Tooltip directive
|
||||
Alpine.directive('tooltip', (el, { expression }) => {
|
||||
el._tooltip = expression;
|
||||
});
|
||||
|
||||
// Global tooltip function
|
||||
window.$tooltip = function(text, event) {
|
||||
// Simple tooltip implementation - could be enhanced with a library
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'fixed z-50 bg-gray-900 text-white text-sm rounded py-1 px-2 pointer-events-none';
|
||||
tooltip.textContent = text;
|
||||
tooltip.style.left = event.pageX + 10 + 'px';
|
||||
tooltip.style.top = event.pageY - 30 + 'px';
|
||||
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
setTimeout(() => {
|
||||
if (tooltip.parentNode) {
|
||||
tooltip.parentNode.removeChild(tooltip);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// Global field clearing function
|
||||
window.clearField = function(fieldId) {
|
||||
const field = document.getElementById(fieldId);
|
||||
if (!field) return;
|
||||
|
||||
if (field.type === 'checkbox' || field.type === 'radio') {
|
||||
field.checked = false;
|
||||
} else {
|
||||
field.value = '';
|
||||
}
|
||||
|
||||
// Trigger change event to update filters
|
||||
const form = field.closest('form');
|
||||
if (form) {
|
||||
htmx.trigger(form, 'change');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Enhanced debouncing for text inputs
|
||||
document.addEventListener('input', function(e) {
|
||||
if (e.target.type === 'text' || e.target.type === 'search') {
|
||||
clearTimeout(e.target._debounceTimer);
|
||||
e.target._debounceTimer = setTimeout(() => {
|
||||
htmx.trigger(e.target.form, 'change');
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Mobile filter toggle functionality
|
||||
function toggleMobileFilters() {
|
||||
const filterPanel = document.querySelector('.filter-panel');
|
||||
const backdrop = document.querySelector('.filter-backdrop');
|
||||
|
||||
if (filterPanel) {
|
||||
filterPanel.classList.toggle('is-open');
|
||||
|
||||
// Create backdrop if it doesn't exist
|
||||
if (!backdrop) {
|
||||
const newBackdrop = document.createElement('div');
|
||||
newBackdrop.className = 'filter-backdrop fixed inset-0 bg-black/50 z-40 md:hidden';
|
||||
newBackdrop.onclick = toggleMobileFilters;
|
||||
document.body.appendChild(newBackdrop);
|
||||
} else {
|
||||
backdrop.style.display = filterPanel.classList.contains('is-open') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
document.body.classList.toggle('overflow-hidden', filterPanel.classList.contains('is-open'));
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced toast notification system
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const toastContainer = document.getElementById('toast-container') || createToastContainer();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-notification transform transition-all duration-300 ease-out translate-x-full opacity-0 mb-4 p-4 rounded-lg shadow-lg flex items-center space-x-3 ${getToastClasses(type)}`;
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="flex-shrink-0">
|
||||
${getToastIcon(type)}
|
||||
</div>
|
||||
<div class="flex-1 text-sm font-medium">${message}</div>
|
||||
<button onclick="removeToast(this.parentElement)" class="flex-shrink-0 ml-4 text-current opacity-70 hover:opacity-100 transition-opacity">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-x-full', 'opacity-0');
|
||||
}, 100);
|
||||
|
||||
// Auto remove
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeToast(toast), duration);
|
||||
}
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
container.className = 'fixed top-4 right-4 z-50 max-w-sm w-full';
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function removeToast(toast) {
|
||||
toast.classList.add('translate-x-full', 'opacity-0');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}
|
||||
|
||||
function getToastClasses(type) {
|
||||
const classes = {
|
||||
success: 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800',
|
||||
error: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800',
|
||||
warning: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800',
|
||||
info: 'bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800'
|
||||
};
|
||||
return classes[type] || classes.info;
|
||||
}
|
||||
|
||||
function getToastIcon(type) {
|
||||
const icons = {
|
||||
success: `<svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>`,
|
||||
error: `<svg class="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>`,
|
||||
warning: `<svg class="w-5 h-5 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>`,
|
||||
info: `<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||
</svg>`
|
||||
};
|
||||
return icons[type] || icons.info;
|
||||
}
|
||||
|
||||
// Enhanced filter manager with additional functionality
|
||||
function enhancedFilterManager() {
|
||||
return {
|
||||
init() {
|
||||
this.setupKeyboardShortcuts();
|
||||
this.setupIntersectionObserver();
|
||||
this.setupTouchGestures();
|
||||
},
|
||||
|
||||
setupKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Escape to close mobile filters
|
||||
if (e.key === 'Escape') {
|
||||
const filterPanel = document.querySelector('.filter-panel.is-open');
|
||||
if (filterPanel) {
|
||||
toggleMobileFilters();
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + K to focus search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
const searchInput = document.querySelector('.search-input');
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
showToast('Search focused - start typing!', 'info', 2000);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
setupIntersectionObserver() {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('fade-in-scale');
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
// Observe park cards for scroll animations
|
||||
document.querySelectorAll('.park-card').forEach(card => {
|
||||
observer.observe(card);
|
||||
});
|
||||
},
|
||||
|
||||
setupTouchGestures() {
|
||||
let startY = 0;
|
||||
let currentY = 0;
|
||||
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
startY = e.touches[0].clientY;
|
||||
});
|
||||
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
currentY = e.touches[0].clientY;
|
||||
});
|
||||
|
||||
document.addEventListener('touchend', () => {
|
||||
const deltaY = startY - currentY;
|
||||
|
||||
// Swipe up to show filters (mobile)
|
||||
if (deltaY > 50 && window.innerWidth < 768) {
|
||||
const filterPanel = document.querySelector('.filter-panel');
|
||||
if (filterPanel && !filterPanel.classList.contains('is-open')) {
|
||||
toggleMobileFilters();
|
||||
showToast('Filters opened', 'info', 1500);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize enhanced functionality when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const enhanced = enhancedFilterManager();
|
||||
enhanced.init();
|
||||
|
||||
// Show welcome message
|
||||
setTimeout(() => {
|
||||
showToast('Welcome! Use Ctrl+K to focus search or swipe up for filters.', 'info', 4000);
|
||||
}, 1000);
|
||||
});
|
||||
</script>
|
||||
28
backend/templates/core/search/filters.html
Normal file
28
backend/templates/core/search/filters.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<form hx-get="{% url 'search:search' %}" hx-target="#search-results" hx-swap="outerHTML" class="space-y-4">
|
||||
{% for field in filters.form %}
|
||||
<div class="flex flex-col">
|
||||
<label for="{{ field.id_for_label }}" class="text-sm font-medium text-gray-700">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
{{ field }}
|
||||
</div>
|
||||
{% if field.help_text %}
|
||||
<p class="text-sm text-gray-500">{{ field.help_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="flex justify-between">
|
||||
<button type="submit"
|
||||
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-xs text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Apply Filters
|
||||
</button>
|
||||
{% if applied_filters %}
|
||||
<a href="{% url 'search:search' %}"
|
||||
class="inline-flex justify-center py-2 px-4 border border-gray-300 shadow-xs text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Clear Filters
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
183
backend/templates/core/search/layouts/filtered_list.html
Normal file
183
backend/templates/core/search/layouts/filtered_list.html
Normal file
@@ -0,0 +1,183 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Parks - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
{# Enhanced Container with Modern Responsive Design #}
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 lg:py-8">
|
||||
<div class="grid grid-cols-1 xl:grid-cols-4 gap-6 lg:gap-8 max-w-7xl mx-auto">
|
||||
{# Enhanced Filters Sidebar with Sticky Positioning #}
|
||||
<aside class="xl:col-span-1 order-2 xl:order-1">
|
||||
<div class="sticky top-4 z-30 space-y-6">
|
||||
{# Mobile Filter Toggle Container #}
|
||||
<div class="xl:hidden">
|
||||
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-xl shadow-lg">
|
||||
<button type="button"
|
||||
id="mobile-filter-toggle"
|
||||
class="w-full flex items-center justify-between p-4 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-700/50 rounded-xl transition-all duration-200"
|
||||
aria-expanded="false"
|
||||
aria-controls="mobile-filter-panel">
|
||||
<span class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-3 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.414A1 1 0 013 6.707V4z"/>
|
||||
</svg>
|
||||
Filters & Search
|
||||
</span>
|
||||
<svg class="w-5 h-5 text-gray-400 transform transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Filter Content Container #}
|
||||
<div id="mobile-filter-panel" class="hidden xl:block">
|
||||
<div class="xl:max-h-[calc(100vh-2rem)] xl:overflow-y-auto xl:scrollbar-thin xl:scrollbar-thumb-gray-300 dark:xl:scrollbar-thumb-gray-600 xl:scrollbar-track-transparent">
|
||||
{% block filter_section %}
|
||||
<!-- DEBUG: base filtered_list.html filter_section block - timestamp: 2025-08-21 -->
|
||||
{% include "core/search/components/filter_form.html" %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{# Enhanced Results Section with Better Responsiveness #}
|
||||
<main class="xl:col-span-3 order-1 xl:order-2">
|
||||
<div id="results-container" class="space-y-6">
|
||||
{# Enhanced Header Section #}
|
||||
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-xl shadow-lg">
|
||||
<div class="p-4 lg:p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h2 class="text-xl lg:text-2xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 bg-clip-text text-transparent">
|
||||
Parks
|
||||
<span class="text-sm lg:text-base font-normal text-gray-500 dark:text-gray-400">
|
||||
({{ page_obj.paginator.count }} found)
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{% block list_actions %}
|
||||
{# Custom actions can be added here by extending views #}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Results Content #}
|
||||
{% block results_list %}
|
||||
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-xl shadow-lg overflow-hidden">
|
||||
<div class="p-4 lg:p-6">
|
||||
{% include results_template|default:"search/partials/generic_results.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{# Enhanced Pagination #}
|
||||
{% if is_paginated %}
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-xl shadow-lg px-6 py-4">
|
||||
{% include "search/components/pagination.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Enhanced Mobile Filter Panel JavaScript #}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const mobileToggle = document.getElementById('mobile-filter-toggle');
|
||||
const mobilePanel = document.getElementById('mobile-filter-panel');
|
||||
|
||||
if (mobileToggle && mobilePanel) {
|
||||
mobileToggle.addEventListener('click', function() {
|
||||
const isExpanded = mobileToggle.getAttribute('aria-expanded') === 'true';
|
||||
const newState = !isExpanded;
|
||||
|
||||
// Update aria-expanded
|
||||
mobileToggle.setAttribute('aria-expanded', newState.toString());
|
||||
|
||||
// Toggle panel visibility with animation
|
||||
if (newState) {
|
||||
mobilePanel.classList.remove('hidden');
|
||||
mobilePanel.style.maxHeight = '0px';
|
||||
mobilePanel.style.overflow = 'hidden';
|
||||
mobilePanel.style.transition = 'max-height 0.3s ease-out';
|
||||
|
||||
// Trigger reflow
|
||||
mobilePanel.offsetHeight;
|
||||
|
||||
// Expand
|
||||
mobilePanel.style.maxHeight = mobilePanel.scrollHeight + 'px';
|
||||
|
||||
// Clean up after animation
|
||||
setTimeout(() => {
|
||||
mobilePanel.style.maxHeight = '';
|
||||
mobilePanel.style.overflow = '';
|
||||
mobilePanel.style.transition = '';
|
||||
}, 300);
|
||||
} else {
|
||||
mobilePanel.style.maxHeight = mobilePanel.scrollHeight + 'px';
|
||||
mobilePanel.style.overflow = 'hidden';
|
||||
mobilePanel.style.transition = 'max-height 0.3s ease-in';
|
||||
|
||||
// Trigger reflow
|
||||
mobilePanel.offsetHeight;
|
||||
|
||||
// Collapse
|
||||
mobilePanel.style.maxHeight = '0px';
|
||||
|
||||
// Hide after animation
|
||||
setTimeout(() => {
|
||||
mobilePanel.classList.add('hidden');
|
||||
mobilePanel.style.maxHeight = '';
|
||||
mobilePanel.style.overflow = '';
|
||||
mobilePanel.style.transition = '';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Rotate arrow icon
|
||||
const arrow = mobileToggle.querySelector('svg:last-child');
|
||||
if (arrow) {
|
||||
arrow.style.transform = newState ? 'rotate(180deg)' : 'rotate(0deg)';
|
||||
}
|
||||
});
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && mobileToggle.getAttribute('aria-expanded') === 'true') {
|
||||
mobileToggle.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Close when clicking outside on mobile
|
||||
document.addEventListener('click', function(e) {
|
||||
if (window.innerWidth < 1280 && // xl breakpoint
|
||||
!mobileToggle.contains(e.target) &&
|
||||
!mobilePanel.contains(e.target) &&
|
||||
mobileToggle.getAttribute('aria-expanded') === 'true') {
|
||||
mobileToggle.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Handle browser back/forward with HTMX
|
||||
document.body.addEventListener('htmx:beforeOnLoad', function(evt) {
|
||||
if (evt.detail.requestConfig.verb === "get") {
|
||||
history.replaceState(null, '', evt.detail.requestConfig.path);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
332
backend/templates/core/search/location_results.html
Normal file
332
backend/templates/core/search/location_results.html
Normal file
@@ -0,0 +1,332 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Location Search - ThrillWiki{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.search-result-card {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.search-result-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.distance-badge {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.content-type-badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Search Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Location Search Results
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
Found {{ total_results }} result{{ total_results|pluralize }} across parks, rides, and companies
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
<!-- Enhanced Search Filters -->
|
||||
<div class="lg:w-1/4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 sticky top-4">
|
||||
<h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Search Filters</h2>
|
||||
|
||||
<form hx-get="{% url 'search:location_search' %}"
|
||||
hx-target="#search-results"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#search-loading"
|
||||
class="space-y-4">
|
||||
|
||||
<!-- Text Search -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ search_form.q.label }}
|
||||
</label>
|
||||
{{ search_form.q }}
|
||||
</div>
|
||||
|
||||
<!-- Location Search -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ search_form.location.label }}
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
{{ search_form.location }}
|
||||
{{ search_form.lat }}
|
||||
{{ search_form.lng }}
|
||||
<div class="flex gap-2">
|
||||
<button type="button"
|
||||
id="use-my-location"
|
||||
class="flex-1 px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300">
|
||||
📍 Use My Location
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radius -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ search_form.radius_km.label }}
|
||||
</label>
|
||||
{{ search_form.radius_km }}
|
||||
</div>
|
||||
|
||||
<!-- Content Types -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Search In
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
{{ search_form.search_parks }}
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">{{ search_form.search_parks.label }}</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
{{ search_form.search_rides }}
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">{{ search_form.search_rides.label }}</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
{{ search_form.search_companies }}
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">{{ search_form.search_companies.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Geographic Filters -->
|
||||
<div class="border-t pt-4">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Geographic Filters</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
{{ search_form.country }}
|
||||
{{ search_form.state }}
|
||||
{{ search_form.city }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
🔍 Search
|
||||
</button>
|
||||
|
||||
{% if request.GET %}
|
||||
<a href="{% url 'search:location_search' %}"
|
||||
class="block w-full text-center bg-gray-100 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-200 transition-colors dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600">
|
||||
Clear Filters
|
||||
</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div class="lg:w-3/4" id="search-results">
|
||||
<!-- Loading indicator -->
|
||||
<div id="search-loading" class="hidden">
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if results %}
|
||||
<!-- Results Summary -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow mb-6 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ total_results }} Result{{ total_results|pluralize }} Found
|
||||
</h2>
|
||||
{% if has_location_filter %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Sorted by distance from your location
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="flex gap-4 text-sm">
|
||||
{% if grouped_results.parks %}
|
||||
<span class="bg-green-100 text-green-800 px-2 py-1 rounded dark:bg-green-900 dark:text-green-300">
|
||||
{{ grouped_results.parks|length }} Park{{ grouped_results.parks|length|pluralize }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if grouped_results.rides %}
|
||||
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded dark:bg-blue-900 dark:text-blue-300">
|
||||
{{ grouped_results.rides|length }} Ride{{ grouped_results.rides|length|pluralize }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if grouped_results.companies %}
|
||||
<span class="bg-purple-100 text-purple-800 px-2 py-1 rounded dark:bg-purple-900 dark:text-purple-300">
|
||||
{{ grouped_results.companies|length }} Compan{{ grouped_results.companies|length|pluralize:"y,ies" }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div class="space-y-4">
|
||||
{% for result in results %}
|
||||
<div class="search-result-card bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<!-- Header with type badge -->
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="content-type-badge px-2 py-1 rounded-full text-xs
|
||||
{% if result.content_type == 'park' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300
|
||||
{% elif result.content_type == 'ride' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300
|
||||
{% else %}bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300{% endif %}">
|
||||
{{ result.content_type|title }}
|
||||
</span>
|
||||
|
||||
{% if result.distance_km %}
|
||||
<span class="distance-badge text-white px-2 py-1 rounded-full text-xs">
|
||||
{{ result.distance_km|floatformat:1 }} km away
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{% if result.url %}
|
||||
<a href="{{ result.url }}" class="hover:text-blue-600 dark:hover:text-blue-400">
|
||||
{{ result.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ result.name }}
|
||||
{% endif %}
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
{% if result.description %}
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-3 line-clamp-2">
|
||||
{{ result.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location Info -->
|
||||
{% if result.city or result.address %}
|
||||
<div class="flex items-center text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{% if result.address %}
|
||||
{{ result.address }}
|
||||
{% else %}
|
||||
{{ result.city }}{% if result.state %}, {{ result.state }}{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tags and Status -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if result.status %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||
{{ result.status }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if result.rating %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300">
|
||||
★ {{ result.rating|floatformat:1 }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% for tag in result.tags %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||
{{ tag|title }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map link -->
|
||||
{% if result.latitude and result.longitude %}
|
||||
<div class="ml-4">
|
||||
<a href="{% url 'maps:universal_map' %}?lat={{ result.latitude }}&lng={{ result.longitude }}&zoom=15"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600">
|
||||
🗺️ View on Map
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- No Results -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
|
||||
<div class="text-gray-400 mb-4">
|
||||
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No results found</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Try adjusting your search criteria or expanding your search radius.
|
||||
</p>
|
||||
<div class="space-y-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>• Try broader search terms</p>
|
||||
<p>• Increase the search radius</p>
|
||||
<p>• Check spelling and try different keywords</p>
|
||||
<p>• Remove some filters to see more results</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Geolocation support
|
||||
const useLocationBtn = document.getElementById('use-my-location');
|
||||
const latInput = document.getElementById('lat-input');
|
||||
const lngInput = document.getElementById('lng-input');
|
||||
const locationInput = document.getElementById('location-input');
|
||||
|
||||
if (useLocationBtn && 'geolocation' in navigator) {
|
||||
useLocationBtn.addEventListener('click', function() {
|
||||
this.textContent = '📍 Getting location...';
|
||||
this.disabled = true;
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
function(position) {
|
||||
latInput.value = position.coords.latitude;
|
||||
lngInput.value = position.coords.longitude;
|
||||
locationInput.value = `${position.coords.latitude}, ${position.coords.longitude}`;
|
||||
useLocationBtn.textContent = '✅ Location set';
|
||||
setTimeout(() => {
|
||||
useLocationBtn.textContent = '📍 Use My Location';
|
||||
useLocationBtn.disabled = false;
|
||||
}, 2000);
|
||||
},
|
||||
function(error) {
|
||||
useLocationBtn.textContent = '❌ Location failed';
|
||||
console.error('Geolocation error:', error);
|
||||
setTimeout(() => {
|
||||
useLocationBtn.textContent = '📍 Use My Location';
|
||||
useLocationBtn.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
);
|
||||
});
|
||||
} else if (useLocationBtn) {
|
||||
useLocationBtn.style.display = 'none';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="{% static 'js/location-search.js' %}"></script>
|
||||
{% endblock %}
|
||||
134
backend/templates/core/search/partials/generic_results.html
Normal file
134
backend/templates/core/search/partials/generic_results.html
Normal file
@@ -0,0 +1,134 @@
|
||||
<div class="divide-y">
|
||||
{% for object in object_list %}
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold">
|
||||
<a href="{{ object.get_absolute_url }}" class="hover:text-blue-600">
|
||||
{{ object }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{% if object.description %}
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
{{ object.description|truncatewords:30 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% block object_metadata %}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{% if object.created_at %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Added {{ object.created_at|date }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if object.average_rating %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{{ object.average_rating }} ★
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if object.location.exists %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ object.location.first.get_formatted_address }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
<p>No {{ view.model|model_name_plural }} found matching your criteria.</p>
|
||||
{% if applied_filters %}
|
||||
<p class="mt-2">
|
||||
<a href="{{ request.path }}"
|
||||
class="text-blue-600 hover:text-blue-500"
|
||||
hx-get="{{ request.path }}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true">
|
||||
Clear all filters
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<div class="px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
hx-get="?page={{ page_obj.previous_page_number }}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true">
|
||||
Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}"
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
hx-get="?page={{ page_obj.next_page_number }}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true">
|
||||
Next
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Showing <span class="font-medium">{{ page_obj.start_index }}</span>
|
||||
to <span class="font-medium">{{ page_obj.end_index }}</span>
|
||||
of <span class="font-medium">{{ page_obj.paginator.count }}</span>
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-xs -space-x-px" aria-label="Pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
hx-get="?page={{ page_obj.previous_page_number }}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true">
|
||||
<span class="sr-only">Previous</span>
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for i in page_obj.paginator.page_range %}
|
||||
{% if i == page_obj.number %}
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-blue-50 text-sm font-medium text-blue-600">
|
||||
{{ i }}
|
||||
</span>
|
||||
{% elif i > page_obj.number|add:"-3" and i < page_obj.number|add:"3" %}
|
||||
<a href="?page={{ i }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
hx-get="?page={{ i }}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true">
|
||||
{{ i }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
hx-get="?page={{ page_obj.next_page_number }}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true">
|
||||
<span class="sr-only">Next</span>
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% load static %}
|
||||
|
||||
<div id="ride-search-results" class="mt-4">
|
||||
{% if rides %}
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Found {{ rides|length }} ride{{ rides|length|pluralize }}
|
||||
</div>
|
||||
|
||||
{% for ride in rides %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700 p-4 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<a href="{% url 'parks:rides:ride_detail' park_slug=ride.park.slug ride_slug=ride.slug %}"
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400">
|
||||
{{ ride.name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
at <a href="{% url 'parks:park_detail' slug=ride.park.slug %}"
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ ride.park.name }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if ride.description %}
|
||||
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300 line-clamp-2">
|
||||
{{ ride.description|truncatewords:20 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{% if ride.get_category_display %}
|
||||
<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">
|
||||
{{ ride.get_category_display }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.status %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{% if ride.status == 'OPERATING' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
||||
{% elif ride.status == 'CLOSED_TEMP' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
|
||||
{% else %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200{% endif %}">
|
||||
{{ ride.get_status_display }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if ride.photos.exists %}
|
||||
<div class="ml-4 shrink-0">
|
||||
{% with ride.photos.first as photo %}
|
||||
<img src="{{ photo.image.url }}"
|
||||
alt="{{ ride.name }}"
|
||||
class="w-16 h-16 rounded-lg object-cover">
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8">
|
||||
<div class="text-gray-500 dark:text-gray-400">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No rides found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Try adjusting your search criteria.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
100
backend/templates/core/search/results.html
Normal file
100
backend/templates/core/search/results.html
Normal file
@@ -0,0 +1,100 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Search Parks - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
<!-- Filters Sidebar -->
|
||||
<div class="lg:w-1/4">
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-bold mb-4">Filter Parks</h2>
|
||||
{% include "search/filters.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div class="lg:w-3/4" id="search-results">
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="p-6 border-b">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold">
|
||||
Search Results
|
||||
<span class="text-sm font-normal text-gray-500">({{ results.count }} found)</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y">
|
||||
{% for park in results %}
|
||||
<div class="p-6 flex flex-col md:flex-row gap-4">
|
||||
<!-- Park Image -->
|
||||
<div class="md:w-48 h-32 bg-gray-200 rounded-lg overflow-hidden">
|
||||
{% if park.photos.exists %}
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-400">
|
||||
No Image
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Park Details -->
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold">
|
||||
<a href="{{ park.get_absolute_url }}" class="hover:text-blue-600">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<div class="mt-2 text-sm text-gray-600">
|
||||
{% if park.formatted_location %}
|
||||
<p>{{ park.formatted_location }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{% if park.average_rating %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{{ park.average_rating }} ★
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
|
||||
{% if park.ride_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{{ park.ride_count }} Rides
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if park.coaster_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{{ park.coaster_count }} Coasters
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if park.description %}
|
||||
<p class="mt-2 text-sm text-gray-600 line-clamp-2">
|
||||
{{ park.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="p-6 text-center text-gray-500">
|
||||
No parks found matching your criteria.
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
87
backend/templates/core/search/ride_search.html
Normal file
87
backend/templates/core/search/ride_search.html
Normal file
@@ -0,0 +1,87 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Search Rides - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Search Rides</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">Find rides across all theme parks</p>
|
||||
</div>
|
||||
|
||||
<!-- Search Form -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700 p-6 mb-6">
|
||||
<form method="get"
|
||||
hx-get="{% url 'search:ride_search' %}"
|
||||
hx-target="#ride-search-results"
|
||||
hx-trigger="input changed delay:300ms from:input[name='ride'], submit"
|
||||
hx-indicator="#search-loading">
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="{{ search_form.ride.id_for_label }}"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Search Rides
|
||||
</label>
|
||||
{{ search_form.ride }}
|
||||
{% if search_form.ride.help_text %}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ search_form.ride.help_text }}</p>
|
||||
{% endif %}
|
||||
{% if search_form.ride.errors %}
|
||||
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||
{{ search_form.ride.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-xs text-white bg-blue-600 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">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
Search
|
||||
</button>
|
||||
|
||||
<div id="search-loading" class="htmx-indicator">
|
||||
<div class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Searching...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div id="ride-search-results">
|
||||
{% include "search/partials/ride_search_results.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Clear search results when input is empty
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.querySelector('input[name="ride"]');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
if (this.value.trim() === '') {
|
||||
const resultsContainer = document.getElementById('ride-search-results');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.innerHTML = '<div class="text-center text-gray-500 dark:text-gray-400 py-8">Start typing to search for rides...</div>';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
121
backend/templates/designers/designer_detail.html
Normal file
121
backend/templates/designers/designer_detail.html
Normal file
@@ -0,0 +1,121 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ designer.name }} - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="grid grid-cols-1 gap-6 mb-8 lg:grid-cols-3">
|
||||
<!-- Designer Info -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">{{ designer.name }}</h1>
|
||||
{% if designer.description %}
|
||||
<div class="mt-4 prose dark:prose-invert max-w-none">
|
||||
{{ designer.description|linebreaks }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Card -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Quick Stats</h2>
|
||||
<dl class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Rides</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-blue-600 dark:text-blue-400">{{ stats.total_rides }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Roller Coasters</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-blue-600 dark:text-blue-400">{{ stats.total_coasters }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Parks</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-blue-600 dark:text-blue-400">{{ stats.total_parks }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Countries</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-blue-600 dark:text-blue-400">{{ stats.total_countries }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{% if designer.website %}
|
||||
<div class="mt-6">
|
||||
<a href="{{ designer.website }}" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
<i class="mr-2 fas fa-external-link-alt"></i>
|
||||
Visit Website
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rides List -->
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-6 text-2xl font-semibold text-gray-900 dark:text-white">Designed Rides</h2>
|
||||
|
||||
{% if rides %}
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for ride in rides %}
|
||||
<div class="p-4 transition-shadow rounded-lg bg-gray-50 hover:shadow-md dark:bg-gray-700/50">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<a href="{% url 'parks:rides:ride_detail' park_slug=ride.park.slug ride_slug=ride.slug %}"
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400">
|
||||
{{ ride.name }}
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-sm 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>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full dark:bg-blue-700 dark:text-blue-50">
|
||||
{{ ride.get_category_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mt-4">
|
||||
{% if ride.opening_date %}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Opened</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{{ ride.opening_date }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if ride.manufacturer %}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Manufacturer</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{{ ride.manufacturer.name }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if ride.category == 'RC' and ride.coaster_stats %}
|
||||
{% if ride.coaster_stats.height_ft %}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Height</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{{ ride.coaster_stats.height_ft }} ft</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if ride.coaster_stats.speed_mph %}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Speed</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{{ ride.coaster_stats.speed_mph }} mph</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 dark:text-gray-400">No rides found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
37
backend/templates/environment_and_settings.html
Normal file
37
backend/templates/environment_and_settings.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Django Environment and Settings</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Django Environment Variables</h1>
|
||||
<table border="1">
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
{% for key, value in env_vars.items %}
|
||||
<tr>
|
||||
<td>{{ key }}</td>
|
||||
<td>{{ value }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<h1>Django Settings</h1>
|
||||
<table border="1">
|
||||
<tr>
|
||||
<th>Setting</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
{% for key, value in settings_vars.items %}
|
||||
<tr>
|
||||
<td>{{ key }}</td>
|
||||
<td>{{ value }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
199
backend/templates/home.html
Normal file
199
backend/templates/home.html
Normal file
@@ -0,0 +1,199 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}ThrillWiki - Theme Parks & Attractions Guide{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Hero Section -->
|
||||
<div class="mb-12 bg-white border border-gray-200 rounded-lg shadow-lg dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="px-4 py-12 text-center">
|
||||
<h1 class="mb-6 text-4xl font-bold text-gray-900 md:text-5xl lg:text-6xl dark:text-white">
|
||||
Welcome to ThrillWiki
|
||||
</h1>
|
||||
<p class="max-w-3xl mx-auto mb-8 text-xl text-gray-600 md:text-2xl dark:text-gray-300">
|
||||
Your ultimate guide to theme parks and attractions worldwide
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
<a href="{% url 'parks:park_list' %}"
|
||||
class="px-8 py-3 text-lg btn-primary">
|
||||
Explore Parks
|
||||
</a>
|
||||
<a href="{% url 'rides:global_ride_list' %}"
|
||||
class="px-8 py-3 text-lg btn-secondary">
|
||||
View Rides
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<div class="grid-adaptive-sm mb-12">
|
||||
<!-- Total Parks -->
|
||||
<a href="{% url 'parks:park_list' %}"
|
||||
class="flex flex-col items-center justify-center p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1 hover:shadow-xl">
|
||||
<div class="mb-2 text-4xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ stats.total_parks }}
|
||||
</div>
|
||||
<div class="text-xl text-gray-600 dark:text-gray-300">
|
||||
Theme Parks
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Total Attractions -->
|
||||
<a href="{% url 'rides:global_ride_list' %}"
|
||||
class="flex flex-col items-center justify-center p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1 hover:shadow-xl">
|
||||
<div class="mb-2 text-4xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ stats.ride_count }}
|
||||
</div>
|
||||
<div class="text-xl text-gray-600 dark:text-gray-300">
|
||||
Attractions
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Total Roller Coasters -->
|
||||
<a href="{% url 'rides:global_roller_coasters' %}"
|
||||
class="flex flex-col items-center justify-center p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1 hover:shadow-xl">
|
||||
<div class="mb-2 text-4xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ stats.coaster_count }}
|
||||
</div>
|
||||
<div class="text-xl text-gray-600 dark:text-gray-300">
|
||||
Roller Coasters
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Featured Content -->
|
||||
<div class="grid-adaptive">
|
||||
<!-- Trending Parks -->
|
||||
<div class="p-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Trending Parks
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{% for park in popular_parks %}
|
||||
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||
class="relative block h-48 overflow-hidden transition-all rounded-lg group hover:-translate-y-1 hover:shadow-xl"
|
||||
{% if park.photos.first %}
|
||||
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7)), url('{{ park.photos.first.image.url }}') center/cover no-repeat;"
|
||||
{% else %}
|
||||
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7));"
|
||||
{% endif %}>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 text-white">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ park.name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-200">
|
||||
{{ park.ride_count }} rides, {{ park.coaster_count }} coasters
|
||||
</div>
|
||||
{% if park.average_rating %}
|
||||
<div class="absolute top-0 right-0 p-2 text-yellow-400">
|
||||
<span class="mr-1">★</span>
|
||||
<span>{{ park.average_rating|floatformat:1 }}/10</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-sm text-gray-400">Rating not available</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% empty %}
|
||||
<p class="text-gray-600 dark:text-gray-400">No trending parks found.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trending Rides -->
|
||||
<div class="p-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Trending Rides
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{% for ride in popular_rides %}
|
||||
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
|
||||
class="relative block h-48 overflow-hidden transition-all rounded-lg group hover:-translate-y-1 hover:shadow-xl"
|
||||
{% if ride.photos.first %}
|
||||
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7)), url('{{ ride.photos.first.image.url }}') center/cover no-repeat;"
|
||||
{% else %}
|
||||
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7));"
|
||||
{% endif %}>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 text-white">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ ride.name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-200">
|
||||
at {{ ride.park.name }}
|
||||
</div>
|
||||
{% if ride.average_rating %}
|
||||
<div class="flex items-center mt-1 text-yellow-400">
|
||||
<span class="mr-1">★</span>
|
||||
<span>{{ ride.average_rating|floatformat:1 }}/10</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-sm text-gray-400">Rating not available</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% empty %}
|
||||
<p class="text-gray-600 dark:text-gray-400">No trending rides found.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highest Rated -->
|
||||
<div class="p-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Highest Rated
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{% for item in highest_rated %}
|
||||
{% if item.park %}
|
||||
<!-- This is a ride -->
|
||||
<a href="{% url 'parks:rides:ride_detail' item.park.slug item.slug %}"
|
||||
class="relative block h-48 overflow-hidden transition-all rounded-lg group hover:-translate-y-1 hover:shadow-xl"
|
||||
{% if item.photos.first %}
|
||||
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7)), url('{{ item.photos.first.image.url }}') center/cover no-repeat;"
|
||||
{% else %}
|
||||
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7));"
|
||||
{% endif %}>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 text-white">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-200">
|
||||
at {{ item.park.name }}
|
||||
</div>
|
||||
<div class="flex items-center mt-1 text-yellow-400">
|
||||
<span class="mr-1">★</span>
|
||||
<span>{{ item.average_rating|floatformat:1 }}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<!-- This is a park -->
|
||||
<a href="{% url 'parks:park_detail' item.slug %}"
|
||||
class="relative block h-48 overflow-hidden transition-all rounded-lg group hover:-translate-y-1 hover:shadow-xl"
|
||||
{% if item.photos.first %}
|
||||
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7)), url('{{ item.photos.first.image.url }}') center/cover no-repeat;"
|
||||
{% else %}
|
||||
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7));"
|
||||
{% endif %}>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 text-white">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-200">
|
||||
{{ item.ride_count }} rides, {{ item.coaster_count }} coasters
|
||||
</div>
|
||||
<div class="absolute top-0 right-0 p-2 text-yellow-400">
|
||||
<span class="mr-1">★</span>
|
||||
<span>{{ item.average_rating|floatformat:1 }}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% empty %}
|
||||
<p class="text-gray-600 dark:text-gray-400">No rated items found.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
17
backend/templates/location/partials/search_results.html
Normal file
17
backend/templates/location/partials/search_results.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<div class="search-results-container">
|
||||
{% if results %}
|
||||
{% for result in results %}
|
||||
<div class="p-2 cursor-pointer hover:bg-gray-100"
|
||||
data-action="click->location-map#selectLocation"
|
||||
data-result="{{ result|json }}">
|
||||
<div class="font-medium">{{ result.name }}</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{% if result.address.city %}{{ result.address.city }}, {% endif %}
|
||||
{{ result.address.country }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="p-2 text-gray-500">No results found</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
279
backend/templates/location/widget.html
Normal file
279
backend/templates/location/widget.html
Normal file
@@ -0,0 +1,279 @@
|
||||
{% load static %}
|
||||
|
||||
<style>
|
||||
/* Ensure map container and its elements stay below other UI elements */
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
.leaflet-control {
|
||||
z-index: 2 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="location-widget" id="locationWidget">
|
||||
{# Search Form #}
|
||||
<div class="relative mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Search Location
|
||||
</label>
|
||||
<input type="text"
|
||||
id="locationSearch"
|
||||
class="relative w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Search for a location..."
|
||||
autocomplete="off"
|
||||
style="z-index: 10;">
|
||||
<div id="searchResults"
|
||||
style="position: absolute; top: 100%; left: 0; right: 0; z-index: 1000;"
|
||||
class="hidden w-full mt-1 overflow-auto bg-white border rounded-md shadow-lg max-h-60 dark:bg-gray-700 dark:border-gray-600">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Map Container #}
|
||||
<div class="relative mb-4" style="z-index: 1;">
|
||||
<div id="locationMap" class="h-[300px] w-full rounded-lg border border-gray-300 dark:border-gray-600"></div>
|
||||
</div>
|
||||
|
||||
{# Location Form Fields #}
|
||||
<div class="relative grid grid-cols-1 gap-4 md:grid-cols-2" style="z-index: 10;">
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Name
|
||||
</label>
|
||||
<input type="text"
|
||||
name="location_name"
|
||||
id="locationName"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value="{{ form.location_name.value|default:'' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Type
|
||||
</label>
|
||||
<input type="text"
|
||||
name="location_type"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value="amusement_park"
|
||||
readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Street Address
|
||||
</label>
|
||||
<input type="text"
|
||||
name="street_address"
|
||||
id="streetAddress"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value="{{ form.street_address.value|default:'' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
City
|
||||
</label>
|
||||
<input type="text"
|
||||
name="city"
|
||||
id="city"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value="{{ form.city.value|default:'' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
State/Region
|
||||
</label>
|
||||
<input type="text"
|
||||
name="state"
|
||||
id="state"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value="{{ form.state.value|default:'' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Country
|
||||
</label>
|
||||
<input type="text"
|
||||
name="country"
|
||||
id="country"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value="{{ form.country.value|default:'' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Postal Code
|
||||
</label>
|
||||
<input type="text"
|
||||
name="postal_code"
|
||||
id="postalCode"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value="{{ form.postal_code.value|default:'' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Hidden Coordinate Fields #}
|
||||
<div class="hidden">
|
||||
<input type="hidden" name="latitude" id="latitude" value="{{ form.latitude.value|default:'' }}">
|
||||
<input type="hidden" name="longitude" id="longitude" value="{{ form.longitude.value|default:'' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let map = null;
|
||||
let marker = null;
|
||||
const searchInput = document.getElementById('locationSearch');
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
let searchTimeout;
|
||||
|
||||
// Initialize map
|
||||
function initMap() {
|
||||
map = L.map('locationMap').setView([0, 0], 2);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Initialize with existing coordinates if available
|
||||
const initialLat = document.getElementById('latitude').value;
|
||||
const initialLng = document.getElementById('longitude').value;
|
||||
if (initialLat && initialLng) {
|
||||
addMarker(parseFloat(initialLat), parseFloat(initialLng));
|
||||
}
|
||||
|
||||
// Handle map clicks
|
||||
map.on('click', async function(e) {
|
||||
const { lat, lng } = e.latlng;
|
||||
try {
|
||||
const response = await fetch(`/parks/search/reverse-geocode/?lat=${lat}&lon=${lng}`);
|
||||
const data = await response.json();
|
||||
updateLocation(lat, lng, data);
|
||||
} catch (error) {
|
||||
console.error('Reverse geocoding failed:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize map
|
||||
initMap();
|
||||
|
||||
// Handle location search
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = this.value.trim();
|
||||
|
||||
if (!query) {
|
||||
searchResults.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async function() {
|
||||
try {
|
||||
const response = await fetch(`/parks/search/location/?q=${encodeURIComponent(query)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.results && data.results.length > 0) {
|
||||
const resultsHtml = data.results.map((result, index) => `
|
||||
<div class="p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
data-result-index="${index}">
|
||||
<div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
${(result.address && result.address.city) ? result.address.city + ', ' : ''}${(result.address && result.address.country) || ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
searchResults.innerHTML = resultsHtml;
|
||||
searchResults.classList.remove('hidden');
|
||||
|
||||
// Store results data
|
||||
searchResults.dataset.results = JSON.stringify(data.results);
|
||||
|
||||
// Add click handlers
|
||||
searchResults.querySelectorAll('[data-result-index]').forEach(el => {
|
||||
el.addEventListener('click', function() {
|
||||
const results = JSON.parse(searchResults.dataset.results);
|
||||
const result = results[this.dataset.resultIndex];
|
||||
selectLocation(result);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
searchResults.innerHTML = '<div class="p-2 text-gray-500 dark:text-gray-400">No results found</div>';
|
||||
searchResults.classList.remove('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Hide search results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!searchResults.contains(e.target) && e.target !== searchInput) {
|
||||
searchResults.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function addMarker(lat, lng) {
|
||||
if (marker) {
|
||||
marker.remove();
|
||||
}
|
||||
marker = L.marker([lat, lng]).addTo(map);
|
||||
map.setView([lat, lng], 13);
|
||||
}
|
||||
|
||||
function updateLocation(lat, lng, data) {
|
||||
// Update coordinates
|
||||
document.getElementById('latitude').value = lat || '';
|
||||
document.getElementById('longitude').value = lng || '';
|
||||
|
||||
// Update marker
|
||||
if (lat && lng) {
|
||||
addMarker(lat, lng);
|
||||
}
|
||||
|
||||
// Update form fields
|
||||
const address = data.address || {};
|
||||
document.getElementById('locationName').value = data.name || data.display_name || '';
|
||||
document.getElementById('streetAddress').value =
|
||||
`${address.house_number || ''} ${address.road || address.street || ''}`.trim() || '';
|
||||
document.getElementById('city').value =
|
||||
address.city || address.town || address.village || '';
|
||||
document.getElementById('state').value =
|
||||
address.state || address.region || '';
|
||||
document.getElementById('country').value = address.country || '';
|
||||
document.getElementById('postalCode').value = address.postcode || '';
|
||||
}
|
||||
|
||||
function selectLocation(result) {
|
||||
if (!result) return;
|
||||
|
||||
const lat = parseFloat(result.lat);
|
||||
const lon = parseFloat(result.lon);
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) return;
|
||||
|
||||
// Create a normalized address object
|
||||
const address = {
|
||||
name: result.display_name || result.name || '',
|
||||
address: {
|
||||
house_number: result.address ? result.address.house_number : '',
|
||||
road: result.address ? (result.address.road || result.address.street) : '',
|
||||
city: result.address ? (result.address.city || result.address.town || result.address.village) : '',
|
||||
state: result.address ? (result.address.state || result.address.region) : '',
|
||||
country: result.address ? result.address.country : '',
|
||||
postcode: result.address ? result.address.postcode : ''
|
||||
}
|
||||
};
|
||||
|
||||
updateLocation(lat, lon, address);
|
||||
searchResults.classList.add('hidden');
|
||||
searchInput.value = address.name;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
106
backend/templates/manufacturers/manufacturer_detail.html
Normal file
106
backend/templates/manufacturers/manufacturer_detail.html
Normal file
@@ -0,0 +1,106 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ manufacturer.name }} - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Manufacturer Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">{{ manufacturer.name }}</h1>
|
||||
|
||||
{% if manufacturer.description %}
|
||||
<div class="prose dark:prose-invert max-w-none mb-6">
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400">{{ manufacturer.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Manufacturer Details -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
{% if manufacturer.founded_year %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-xs">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Founded</h3>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ manufacturer.founded_year }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if manufacturer.headquarters %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-xs">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Headquarters</h3>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ manufacturer.headquarters }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-xs">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Rides Manufactured</h3>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ rides.count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rides Section -->
|
||||
{% if rides %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Rides Manufactured</h2>
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for ride in rides %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
{% if ride.main_image %}
|
||||
<img src="{{ ride.main_image.url }}" alt="{{ ride.name }}" class="w-full h-48 object-cover">
|
||||
{% endif %}
|
||||
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
<a href="{% url 'rides:ride_detail' ride.slug %}" class="hover:text-blue-600 dark:hover:text-blue-400">
|
||||
{{ ride.name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{% if ride.park %}
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-2">
|
||||
<a href="{% url 'parks:park_detail' ride.park.slug %}" class="hover:text-blue-600 dark:hover:text-blue-400">
|
||||
{{ ride.park.name }}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-sm text-gray-500 dark:text-gray-500">
|
||||
{% if ride.ride_type %}
|
||||
<p class="mb-1">{{ ride.ride_type }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.opened_date %}
|
||||
<p>Opened {{ ride.opened_date|date:"Y" }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Rides Manufactured</h2>
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-8 text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">No rides currently manufactured by this company.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Additional Information -->
|
||||
{% if manufacturer.website %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Links</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-xs">
|
||||
<a href="{{ manufacturer.website }}" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
|
||||
Official Website
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
63
backend/templates/manufacturers/manufacturer_list.html
Normal file
63
backend/templates/manufacturers/manufacturer_list.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Manufacturers - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Ride Manufacturers</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Companies that manufacture theme park rides and attractions</p>
|
||||
</div>
|
||||
|
||||
<!-- Manufacturers List -->
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for manufacturer in manufacturers %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
<a href="{% url 'manufacturers:manufacturer_detail' manufacturer.slug %}" class="hover:text-blue-600 dark:hover:text-blue-400">
|
||||
{{ manufacturer.name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{% if manufacturer.description %}
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">{{ manufacturer.description|truncatewords:20 }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-sm text-gray-500 dark:text-gray-500">
|
||||
{% if manufacturer.rides_count %}
|
||||
<span class="inline-block mr-4">{{ manufacturer.rides_count }} ride{{ manufacturer.rides_count|pluralize }}</span>
|
||||
{% endif %}
|
||||
{% if manufacturer.founded_year %}
|
||||
<span class="inline-block">Founded {{ manufacturer.founded_year }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-span-full text-center py-12">
|
||||
<p class="text-gray-500 dark:text-gray-400">No manufacturers found.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<div class="mt-8 flex justify-center">
|
||||
<nav class="flex space-x-2">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}" class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Previous</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="px-3 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}" class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Next</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
577
backend/templates/maps/location_list.html
Normal file
577
backend/templates/maps/location_list.html
Normal file
@@ -0,0 +1,577 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Location Search Results - ThrillWiki{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.location-card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm hover:shadow-md transition-all cursor-pointer border border-gray-200 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.location-card:hover {
|
||||
@apply border-blue-300 dark:border-blue-600 shadow-lg;
|
||||
}
|
||||
|
||||
.location-type-badge {
|
||||
@apply inline-flex items-center px-2 py-1 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.location-type-park {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100;
|
||||
}
|
||||
|
||||
.location-type-ride {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100;
|
||||
}
|
||||
|
||||
.location-type-company {
|
||||
@apply bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@apply inline-flex items-center px-2 py-1 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.status-operating {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100;
|
||||
}
|
||||
|
||||
.status-closed {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100;
|
||||
}
|
||||
|
||||
.status-construction {
|
||||
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100;
|
||||
}
|
||||
|
||||
.status-demolished {
|
||||
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
@apply inline-flex items-center px-3 py-1 rounded-full text-sm font-medium cursor-pointer transition-all;
|
||||
}
|
||||
|
||||
.filter-chip.active {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100;
|
||||
}
|
||||
|
||||
.filter-chip.inactive {
|
||||
@apply bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
@apply flex items-center justify-between mt-8;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
@apply text-sm text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
@apply flex items-center space-x-2;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
@apply px-3 py-2 text-sm font-medium rounded-lg border transition-colors;
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
@apply bg-blue-600 text-white border-blue-600;
|
||||
}
|
||||
|
||||
.pagination-btn.inactive {
|
||||
@apply bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700;
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.search-summary {
|
||||
@apply bg-blue-50 dark:bg-blue-900 dark:bg-opacity-30 rounded-lg p-4 mb-6;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
@apply text-center py-12;
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
@apply animate-pulse;
|
||||
}
|
||||
|
||||
.loading-skeleton .skeleton-text {
|
||||
@apply bg-gray-200 dark:bg-gray-700 rounded h-4;
|
||||
}
|
||||
|
||||
.loading-skeleton .skeleton-text.w-3-4 {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.loading-skeleton .skeleton-text.w-1-2 {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.loading-skeleton .skeleton-text.w-1-4 {
|
||||
width: 25%;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Search Results</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
{% if query %}
|
||||
Search results for "{{ query }}"
|
||||
{% else %}
|
||||
Browse all locations in ThrillWiki
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'maps:universal_map' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<i class="mr-2 fas fa-map"></i>View on Map
|
||||
</a>
|
||||
<a href="{% url 'maps:nearby_locations' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="mr-2 fas fa-search-location"></i>Find Nearby
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<form id="search-form" method="get" class="space-y-4">
|
||||
<!-- Search Input -->
|
||||
<div>
|
||||
<label for="search" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||
<div class="relative">
|
||||
<input type="text" name="q" id="search"
|
||||
class="w-full pl-10 pr-4 py-2 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Search by name, location, or keyword..."
|
||||
value="{{ request.GET.q|default:'' }}"
|
||||
hx-get="{% url 'maps:location_list' %}"
|
||||
hx-trigger="input changed delay:500ms"
|
||||
hx-target="#results-container"
|
||||
hx-include="#search-form"
|
||||
hx-indicator="#search-loading">
|
||||
<i class="absolute left-3 top-1/2 transform -translate-y-1/2 fas fa-search text-gray-400"></i>
|
||||
<div id="search-loading" class="htmx-indicator absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<div class="w-4 h-4 border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Chips -->
|
||||
<div class="space-y-3">
|
||||
<!-- Location Types -->
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Location Types</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label class="filter-chip {% if 'park' in location_types %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="types" value="park" class="hidden"
|
||||
{% if 'park' in location_types %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-tree"></i>Parks
|
||||
</label>
|
||||
<label class="filter-chip {% if 'ride' in location_types %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="types" value="ride" class="hidden"
|
||||
{% if 'ride' in location_types %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-rocket"></i>Rides
|
||||
</label>
|
||||
<label class="filter-chip {% if 'company' in location_types %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="types" value="company" class="hidden"
|
||||
{% if 'company' in location_types %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-building"></i>Companies
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Filters (for parks) -->
|
||||
{% if 'park' in location_types %}
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Park Status</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label class="filter-chip {% if 'OPERATING' in park_statuses %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="park_status" value="OPERATING" class="hidden"
|
||||
{% if 'OPERATING' in park_statuses %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-check-circle"></i>Operating
|
||||
</label>
|
||||
<label class="filter-chip {% if 'CLOSED_TEMP' in park_statuses %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="park_status" value="CLOSED_TEMP" class="hidden"
|
||||
{% if 'CLOSED_TEMP' in park_statuses %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-clock"></i>Temporarily Closed
|
||||
</label>
|
||||
<label class="filter-chip {% if 'CLOSED_PERM' in park_statuses %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="park_status" value="CLOSED_PERM" class="hidden"
|
||||
{% if 'CLOSED_PERM' in park_statuses %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-times-circle"></i>Permanently Closed
|
||||
</label>
|
||||
<label class="filter-chip {% if 'UNDER_CONSTRUCTION' in park_statuses %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="park_status" value="UNDER_CONSTRUCTION" class="hidden"
|
||||
{% if 'UNDER_CONSTRUCTION' in park_statuses %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-hard-hat"></i>Under Construction
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location Filters -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label for="country" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Country</label>
|
||||
<input type="text" name="country" id="country"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Filter by country..."
|
||||
value="{{ request.GET.country|default:'' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="state" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">State/Region</label>
|
||||
<input type="text" name="state" id="state"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Filter by state..."
|
||||
value="{{ request.GET.state|default:'' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="sort" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Sort By</label>
|
||||
<select name="sort" id="sort"
|
||||
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
<option value="name" {% if request.GET.sort == 'name' %}selected{% endif %}>Name (A-Z)</option>
|
||||
<option value="-name" {% if request.GET.sort == '-name' %}selected{% endif %}>Name (Z-A)</option>
|
||||
<option value="location" {% if request.GET.sort == 'location' %}selected{% endif %}>Location</option>
|
||||
<option value="-created_at" {% if request.GET.sort == '-created_at' %}selected{% endif %}>Recently Added</option>
|
||||
{% if 'park' in location_types %}
|
||||
<option value="-ride_count" {% if request.GET.sort == '-ride_count' %}selected{% endif %}>Most Rides</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<i class="mr-2 fas fa-search"></i>Apply Filters
|
||||
</button>
|
||||
<a href="{% url 'maps:location_list' %}"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="mr-2 fas fa-times"></i>Clear All
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Search Summary -->
|
||||
{% if locations %}
|
||||
<div class="search-summary">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-blue-900 dark:text-blue-100">
|
||||
{{ paginator.count }} location{{ paginator.count|pluralize }} found
|
||||
</h3>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-200 mt-1">
|
||||
{% if query %}
|
||||
Showing results for "{{ query }}"
|
||||
{% endif %}
|
||||
{% if location_types %}
|
||||
• Types: {{ location_types|join:", "|title }}
|
||||
{% endif %}
|
||||
{% if request.GET.country %}
|
||||
• Country: {{ request.GET.country }}
|
||||
{% endif %}
|
||||
{% if request.GET.state %}
|
||||
• State: {{ request.GET.state }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-sm text-blue-700 dark:text-blue-200">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Results Container -->
|
||||
<div id="results-container">
|
||||
{% if locations %}
|
||||
<!-- Location Cards -->
|
||||
<div class="grid grid-cols-1 gap-4 mb-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for location in locations %}
|
||||
<div class="location-card"
|
||||
onclick="window.location.href='{{ location.get_absolute_url }}'">
|
||||
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate">
|
||||
{{ location.name }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||
{{ location.formatted_location|default:"Location not specified" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 ml-3">
|
||||
<span class="location-type-badge location-type-{{ location.type }}">
|
||||
{% if location.type == 'park' %}
|
||||
<i class="mr-1 fas fa-tree"></i>Park
|
||||
{% elif location.type == 'ride' %}
|
||||
<i class="mr-1 fas fa-rocket"></i>Ride
|
||||
{% elif location.type == 'company' %}
|
||||
<i class="mr-1 fas fa-building"></i>Company
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type-specific information -->
|
||||
{% if location.type == 'park' %}
|
||||
<div class="space-y-2">
|
||||
{% if location.status %}
|
||||
<div class="flex items-center">
|
||||
<span class="status-badge {% if location.status == 'OPERATING' %}status-operating{% elif location.status == 'CLOSED_TEMP' or location.status == 'CLOSED_PERM' %}status-closed{% elif location.status == 'UNDER_CONSTRUCTION' %}status-construction{% else %}status-demolished{% endif %}">
|
||||
{% if location.status == 'OPERATING' %}
|
||||
<i class="mr-1 fas fa-check-circle"></i>Operating
|
||||
{% elif location.status == 'CLOSED_TEMP' %}
|
||||
<i class="mr-1 fas fa-clock"></i>Temporarily Closed
|
||||
{% elif location.status == 'CLOSED_PERM' %}
|
||||
<i class="mr-1 fas fa-times-circle"></i>Permanently Closed
|
||||
{% elif location.status == 'UNDER_CONSTRUCTION' %}
|
||||
<i class="mr-1 fas fa-hard-hat"></i>Under Construction
|
||||
{% elif location.status == 'DEMOLISHED' %}
|
||||
<i class="mr-1 fas fa-ban"></i>Demolished
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
{% if location.operator %}
|
||||
<i class="mr-2 fas fa-building"></i>
|
||||
<span>{{ location.operator }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if location.ride_count %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-rocket"></i>
|
||||
<span>{{ location.ride_count }} ride{{ location.ride_count|pluralize }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if location.average_rating %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-star text-yellow-500"></i>
|
||||
<span>{{ location.average_rating|floatformat:1 }}/10</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif location.type == 'ride' %}
|
||||
<div class="space-y-2">
|
||||
{% if location.park_name %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-tree"></i>
|
||||
<span>{{ location.park_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if location.manufacturer %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-industry"></i>
|
||||
<span>{{ location.manufacturer }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if location.opening_date %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-calendar"></i>
|
||||
<span>Opened {{ location.opening_date.year }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif location.type == 'company' %}
|
||||
<div class="space-y-2">
|
||||
{% if location.company_type %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-tag"></i>
|
||||
<span>{{ location.get_company_type_display }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if location.founded_year %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-calendar"></i>
|
||||
<span>Founded {{ location.founded_year }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-2 mt-4">
|
||||
<a href="{{ location.get_absolute_url }}"
|
||||
class="flex-1 px-3 py-2 text-sm text-center text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
View Details
|
||||
</a>
|
||||
{% if location.latitude and location.longitude %}
|
||||
<a href="{% url 'maps:nearby_locations' %}?lat={{ location.latitude }}&lng={{ location.longitude }}&radius=25"
|
||||
class="px-3 py-2 text-sm text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors">
|
||||
<i class="fas fa-search-location"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<div class="pagination-container">
|
||||
<div class="pagination-info">
|
||||
Showing {{ page_obj.start_index }}-{{ page_obj.end_index }} of {{ paginator.count }} results
|
||||
</div>
|
||||
|
||||
<div class="pagination-controls">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page=1"
|
||||
class="pagination-btn inactive">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.previous_page_number }}"
|
||||
class="pagination-btn inactive">
|
||||
<i class="fas fa-angle-left"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<span class="pagination-btn active">{{ num }}</span>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
|
||||
class="pagination-btn inactive">{{ num }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.next_page_number }}"
|
||||
class="pagination-btn inactive">
|
||||
<i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ paginator.num_pages }}"
|
||||
class="pagination-btn inactive">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- No Results -->
|
||||
<div class="no-results">
|
||||
<i class="fas fa-search text-6xl text-gray-400 mb-6"></i>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">No locations found</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
{% if query %}
|
||||
No results found for "{{ query }}". Try adjusting your search or filters.
|
||||
{% else %}
|
||||
No locations match your current filters. Try adjusting your search criteria.
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="flex justify-center gap-3">
|
||||
<a href="{% url 'maps:location_list' %}"
|
||||
class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<i class="mr-2 fas fa-refresh"></i>Clear Filters
|
||||
</a>
|
||||
<a href="{% url 'maps:universal_map' %}"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="mr-2 fas fa-map"></i>Browse Map
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Loading Template for HTMX -->
|
||||
<template id="loading-template">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for i in "123456789" %}
|
||||
<div class="location-card loading-skeleton">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex-1">
|
||||
<div class="skeleton-text w-3-4 h-6 mb-2"></div>
|
||||
<div class="skeleton-text w-1-2 h-4"></div>
|
||||
</div>
|
||||
<div class="skeleton-text w-1-4 h-6"></div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="skeleton-text w-1-2 h-4"></div>
|
||||
<div class="skeleton-text w-3-4 h-4"></div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<div class="skeleton-text flex-1 h-8"></div>
|
||||
<div class="skeleton-text w-10 h-8"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Handle filter chip toggles
|
||||
document.querySelectorAll('.filter-chip').forEach(chip => {
|
||||
const checkbox = chip.querySelector('input[type="checkbox"]');
|
||||
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
chip.classList.toggle('active', checkbox.checked);
|
||||
chip.classList.toggle('inactive', !checkbox.checked);
|
||||
|
||||
// Auto-submit form on filter change
|
||||
document.getElementById('search-form').dispatchEvent(new Event('submit'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle form changes
|
||||
document.getElementById('search-form').addEventListener('change', function(e) {
|
||||
if (e.target.name !== 'q') { // Don't auto-submit on search input changes
|
||||
this.submit();
|
||||
}
|
||||
});
|
||||
|
||||
// Show loading state during HTMX requests
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
if (event.target.id === 'results-container') {
|
||||
const template = document.getElementById('loading-template');
|
||||
event.target.innerHTML = template.innerHTML;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle HTMX errors
|
||||
document.addEventListener('htmx:responseError', function(event) {
|
||||
console.error('Search request failed:', event.detail);
|
||||
event.target.innerHTML = `
|
||||
<div class="text-center py-12">
|
||||
<i class="fas fa-exclamation-triangle text-6xl text-red-400 mb-6"></i>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Search Error</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">There was an error performing your search. Please try again.</p>
|
||||
<button onclick="location.reload()"
|
||||
class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<i class="mr-2 fas fa-refresh"></i>Retry
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
581
backend/templates/maps/nearby_locations.html
Normal file
581
backend/templates/maps/nearby_locations.html
Normal file
@@ -0,0 +1,581 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Nearby Locations - ThrillWiki{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
|
||||
<style>
|
||||
.map-container {
|
||||
height: 60vh;
|
||||
min-height: 400px;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.location-card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow cursor-pointer;
|
||||
}
|
||||
|
||||
.location-card:hover {
|
||||
@apply ring-2 ring-blue-500 ring-opacity-50;
|
||||
}
|
||||
|
||||
.location-card.selected {
|
||||
@apply ring-2 ring-blue-500;
|
||||
}
|
||||
|
||||
.location-type-badge {
|
||||
@apply inline-flex items-center px-2 py-1 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.location-type-park {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100;
|
||||
}
|
||||
|
||||
.location-type-ride {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100;
|
||||
}
|
||||
|
||||
.location-type-company {
|
||||
@apply bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100;
|
||||
}
|
||||
|
||||
.distance-badge {
|
||||
@apply inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100;
|
||||
}
|
||||
|
||||
.center-marker {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.center-marker-inner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.location-marker {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.location-marker-inner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.location-marker-park .location-marker-inner {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.location-marker-ride .location-marker-inner {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.location-marker-company .location-marker-inner {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
|
||||
.radius-circle {
|
||||
fill: rgba(59, 130, 246, 0.1);
|
||||
stroke: #3b82f6;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5, 5;
|
||||
}
|
||||
|
||||
.dark .radius-circle {
|
||||
fill: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Nearby Locations</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
{% if center_location %}
|
||||
Locations near {{ center_location.name }}
|
||||
{% elif center_lat and center_lng %}
|
||||
Locations near {{ center_lat|floatformat:4 }}, {{ center_lng|floatformat:4 }}
|
||||
{% else %}
|
||||
Find locations near a specific point
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'maps:universal_map' %}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="mr-2 fas fa-globe"></i>Universal Map
|
||||
</a>
|
||||
<a href="{% url 'maps:park_map' %}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="mr-2 fas fa-map"></i>Park Map
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search for New Location -->
|
||||
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Find Nearby Locations</h3>
|
||||
|
||||
<form id="location-search-form"
|
||||
hx-get="{% url 'maps:nearby_locations' %}"
|
||||
hx-trigger="submit"
|
||||
hx-target="body"
|
||||
hx-push-url="true">
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-3">
|
||||
<!-- Search by Address/Name -->
|
||||
<div class="md:col-span-2">
|
||||
<label for="search-location" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Search Location
|
||||
</label>
|
||||
<input type="text" name="q" id="search-location"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Search by park name, address, or coordinates..."
|
||||
value="{{ request.GET.q|default:'' }}"
|
||||
hx-get="{% url 'maps:htmx_geocode' %}"
|
||||
hx-trigger="input changed delay:500ms"
|
||||
hx-target="#geocode-suggestions"
|
||||
hx-indicator="#geocode-loading">
|
||||
</div>
|
||||
|
||||
<!-- Radius -->
|
||||
<div>
|
||||
<label for="radius" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Radius (miles)
|
||||
</label>
|
||||
<input type="number" name="radius" id="radius"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="50" min="1" max="500"
|
||||
value="{{ request.GET.radius|default:'50' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Geocoding suggestions -->
|
||||
<div id="geocode-suggestions" class="mb-4"></div>
|
||||
<div id="geocode-loading" class="htmx-indicator mb-4">
|
||||
<div class="flex items-center justify-center p-2">
|
||||
<div class="w-4 h-4 border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Searching locations...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Type Filters -->
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Location Types</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label class="location-type-badge location-type-park cursor-pointer">
|
||||
<input type="checkbox" name="types" value="park" class="hidden type-checkbox"
|
||||
{% if 'park' in location_types %}checked{% endif %}>
|
||||
<i class="mr-1 fas fa-tree"></i>Parks
|
||||
</label>
|
||||
<label class="location-type-badge location-type-ride cursor-pointer">
|
||||
<input type="checkbox" name="types" value="ride" class="hidden type-checkbox"
|
||||
{% if 'ride' in location_types %}checked{% endif %}>
|
||||
<i class="mr-1 fas fa-rocket"></i>Rides
|
||||
</label>
|
||||
<label class="location-type-badge location-type-company cursor-pointer">
|
||||
<input type="checkbox" name="types" value="company" class="hidden type-checkbox"
|
||||
{% if 'company' in location_types %}checked{% endif %}>
|
||||
<i class="mr-1 fas fa-building"></i>Companies
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<i class="mr-2 fas fa-search"></i>Search Nearby
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if center_lat and center_lng %}
|
||||
<!-- Results Section -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Map -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Map View</h3>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ nearby_locations|length }} location{{ nearby_locations|length|pluralize }} found
|
||||
</div>
|
||||
</div>
|
||||
<div id="map-container" class="map-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location List -->
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Nearby Locations</h3>
|
||||
|
||||
{% if nearby_locations %}
|
||||
<div id="location-list" class="space-y-3">
|
||||
{% for location in nearby_locations %}
|
||||
<div class="location-card"
|
||||
data-location-id="{{ location.id }}"
|
||||
data-location-type="{{ location.type }}"
|
||||
data-lat="{{ location.latitude }}"
|
||||
data-lng="{{ location.longitude }}"
|
||||
onclick="nearbyMap.selectLocation('{{ location.type }}', {{ location.id }})">
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
||||
{{ location.name }}
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ location.formatted_location|default:"Location not specified" }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="location-type-badge location-type-{{ location.type }}">
|
||||
{% if location.type == 'park' %}
|
||||
<i class="mr-1 fas fa-tree"></i>Park
|
||||
{% elif location.type == 'ride' %}
|
||||
<i class="mr-1 fas fa-rocket"></i>Ride
|
||||
{% elif location.type == 'company' %}
|
||||
<i class="mr-1 fas fa-building"></i>Company
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="distance-badge">
|
||||
<i class="mr-1 fas fa-route"></i>{{ location.distance|floatformat:1 }} miles
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if location.type == 'park' and location.ride_count %}
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-1 fas fa-rocket"></i>{{ location.ride_count }} ride{{ location.ride_count|pluralize }}
|
||||
</div>
|
||||
{% elif location.type == 'ride' and location.park_name %}
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-1 fas fa-tree"></i>{{ location.park_name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8">
|
||||
<i class="fas fa-search text-4xl text-gray-400 mb-4"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400">No locations found within {{ radius }} miles.</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500 mt-2">Try increasing the search radius or adjusting the location types.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- No search performed yet -->
|
||||
<div class="text-center py-12">
|
||||
<i class="fas fa-map-marked-alt text-6xl text-gray-400 mb-6"></i>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Find Nearby Locations</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Enter a location above to discover theme parks, rides, and companies in the area.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location Details Modal -->
|
||||
<div id="location-modal" class="fixed inset-0 z-50 hidden">
|
||||
<!-- Modal content will be loaded here via HTMX -->
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- Leaflet JS -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<script>
|
||||
// Nearby locations map class
|
||||
class NearbyMap {
|
||||
constructor(containerId, options = {}) {
|
||||
this.containerId = containerId;
|
||||
this.options = {
|
||||
center: [{{ center_lat|default:39.8283 }}, {{ center_lng|default:-98.5795 }}],
|
||||
radius: {{ radius|default:50 }},
|
||||
...options
|
||||
};
|
||||
this.map = null;
|
||||
this.markers = [];
|
||||
this.centerMarker = null;
|
||||
this.radiusCircle = null;
|
||||
this.selectedLocation = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Initialize the map
|
||||
this.map = L.map(this.containerId, {
|
||||
center: this.options.center,
|
||||
zoom: this.calculateZoom(this.options.radius),
|
||||
zoomControl: false
|
||||
});
|
||||
|
||||
// Add custom zoom control
|
||||
L.control.zoom({
|
||||
position: 'bottomright'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Add tile layers with dark mode support
|
||||
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
});
|
||||
|
||||
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors, © CARTO'
|
||||
});
|
||||
|
||||
// Set initial tiles based on theme
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
darkTiles.addTo(this.map);
|
||||
} else {
|
||||
lightTiles.addTo(this.map);
|
||||
}
|
||||
|
||||
// Listen for theme changes
|
||||
this.observeThemeChanges(lightTiles, darkTiles);
|
||||
|
||||
// Add center marker and radius circle
|
||||
this.addCenterMarker();
|
||||
this.addRadiusCircle();
|
||||
|
||||
// Add location markers
|
||||
this.addLocationMarkers();
|
||||
}
|
||||
|
||||
observeThemeChanges(lightTiles, darkTiles) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
this.map.removeLayer(lightTiles);
|
||||
this.map.addLayer(darkTiles);
|
||||
} else {
|
||||
this.map.removeLayer(darkTiles);
|
||||
this.map.addLayer(lightTiles);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
}
|
||||
|
||||
calculateZoom(radiusMiles) {
|
||||
// Rough calculation to fit radius in view
|
||||
if (radiusMiles <= 10) return 11;
|
||||
if (radiusMiles <= 25) return 9;
|
||||
if (radiusMiles <= 50) return 8;
|
||||
if (radiusMiles <= 100) return 7;
|
||||
if (radiusMiles <= 250) return 6;
|
||||
return 5;
|
||||
}
|
||||
|
||||
addCenterMarker() {
|
||||
const icon = L.divIcon({
|
||||
className: 'center-marker',
|
||||
html: '<div class="center-marker-inner">📍</div>',
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
});
|
||||
|
||||
this.centerMarker = L.marker(this.options.center, { icon });
|
||||
this.centerMarker.bindPopup('Search Center');
|
||||
this.centerMarker.addTo(this.map);
|
||||
}
|
||||
|
||||
addRadiusCircle() {
|
||||
// Convert miles to meters for radius
|
||||
const radiusMeters = this.options.radius * 1609.34;
|
||||
|
||||
this.radiusCircle = L.circle(this.options.center, {
|
||||
radius: radiusMeters,
|
||||
className: 'radius-circle',
|
||||
fillOpacity: 0.1,
|
||||
color: '#3b82f6',
|
||||
weight: 2,
|
||||
dashArray: '5, 5'
|
||||
});
|
||||
|
||||
this.radiusCircle.addTo(this.map);
|
||||
}
|
||||
|
||||
addLocationMarkers() {
|
||||
{% if nearby_locations %}
|
||||
const locations = {{ nearby_locations|safe }};
|
||||
|
||||
locations.forEach(location => {
|
||||
this.addLocationMarker(location);
|
||||
});
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
addLocationMarker(location) {
|
||||
const icon = this.getLocationIcon(location.type);
|
||||
const marker = L.marker([location.latitude, location.longitude], { icon });
|
||||
|
||||
// Create popup content
|
||||
const popupContent = this.createLocationPopupContent(location);
|
||||
marker.bindPopup(popupContent, { maxWidth: 300 });
|
||||
|
||||
// Add click handler
|
||||
marker.on('click', () => {
|
||||
this.selectLocation(location.type, location.id);
|
||||
});
|
||||
|
||||
marker.addTo(this.map);
|
||||
this.markers.push({ marker, location });
|
||||
}
|
||||
|
||||
getLocationIcon(type) {
|
||||
const typeClass = `location-marker-${type}`;
|
||||
const icons = {
|
||||
'park': '🎢',
|
||||
'ride': '🎠',
|
||||
'company': '🏢'
|
||||
};
|
||||
|
||||
return L.divIcon({
|
||||
className: `location-marker ${typeClass}`,
|
||||
html: `<div class="location-marker-inner">${icons[type] || '📍'}</div>`,
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10]
|
||||
});
|
||||
}
|
||||
|
||||
createLocationPopupContent(location) {
|
||||
const typeIcons = {
|
||||
'park': 'fas fa-tree',
|
||||
'ride': 'fas fa-rocket',
|
||||
'company': 'fas fa-building'
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="text-center">
|
||||
<h3 class="font-semibold mb-2">${location.name}</h3>
|
||||
<div class="text-sm text-gray-600 mb-2">
|
||||
<i class="${typeIcons[location.type]} mr-1"></i>
|
||||
${location.type.charAt(0).toUpperCase() + location.type.slice(1)}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 mb-2">
|
||||
<i class="fas fa-route mr-1"></i>
|
||||
${location.distance.toFixed(1)} miles away
|
||||
</div>
|
||||
${location.formatted_location ? `<div class="text-xs text-gray-500 mb-3">${location.formatted_location}</div>` : ''}
|
||||
<button onclick="nearbyMap.showLocationDetails('${location.type}', ${location.id})"
|
||||
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
|
||||
View Details
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
selectLocation(type, id) {
|
||||
// Remove previous selection
|
||||
document.querySelectorAll('.location-card.selected').forEach(card => {
|
||||
card.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Add selection to new location
|
||||
const card = document.querySelector(`[data-location-type="${type}"][data-location-id="${id}"]`);
|
||||
if (card) {
|
||||
card.classList.add('selected');
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
// Find and highlight marker
|
||||
const markerData = this.markers.find(m =>
|
||||
m.location.type === type && m.location.id === id
|
||||
);
|
||||
|
||||
if (markerData) {
|
||||
// Temporarily highlight the marker
|
||||
markerData.marker.openPopup();
|
||||
this.map.setView([markerData.location.latitude, markerData.location.longitude],
|
||||
Math.max(this.map.getZoom(), 12));
|
||||
}
|
||||
|
||||
this.selectedLocation = { type, id };
|
||||
}
|
||||
|
||||
showLocationDetails(type, id) {
|
||||
htmx.ajax('GET', `{% url 'maps:htmx_location_detail' 'type' 0 %}`.replace('type', type).replace('0', id), {
|
||||
target: '#location-modal',
|
||||
swap: 'innerHTML'
|
||||
}).then(() => {
|
||||
document.getElementById('location-modal').classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize map when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
{% if center_lat and center_lng %}
|
||||
window.nearbyMap = new NearbyMap('map-container', {
|
||||
center: [{{ center_lat }}, {{ center_lng }}],
|
||||
radius: {{ radius|default:50 }}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
// Handle location type filter toggles
|
||||
document.querySelectorAll('.location-type-badge').forEach(badge => {
|
||||
const checkbox = badge.querySelector('input[type="checkbox"]');
|
||||
|
||||
// Set initial state
|
||||
if (checkbox && checkbox.checked) {
|
||||
badge.style.opacity = '1';
|
||||
} else if (checkbox) {
|
||||
badge.style.opacity = '0.5';
|
||||
}
|
||||
|
||||
badge.addEventListener('click', () => {
|
||||
if (checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
badge.style.opacity = checkbox.checked ? '1' : '0.5';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Close modal handler
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'location-modal') {
|
||||
document.getElementById('location-modal').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
618
backend/templates/maps/park_map.html
Normal file
618
backend/templates/maps/park_map.html
Normal file
@@ -0,0 +1,618 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ page_title }} - ThrillWiki{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<!-- Leaflet MarkerCluster CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" />
|
||||
|
||||
<style>
|
||||
.map-container {
|
||||
height: 75vh;
|
||||
min-height: 600px;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.park-status-badge {
|
||||
@apply inline-flex items-center px-2 py-1 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.park-status-operating {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100;
|
||||
}
|
||||
|
||||
.park-status-closed {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100;
|
||||
}
|
||||
|
||||
.park-status-construction {
|
||||
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100;
|
||||
}
|
||||
|
||||
.park-status-demolished {
|
||||
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100;
|
||||
}
|
||||
|
||||
.park-marker {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.park-marker-inner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.park-marker-operating {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.park-marker-closed {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.park-marker-construction {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.park-marker-demolished {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.park-info-popup {
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.park-info-popup h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.park-info-popup .park-meta {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.dark .park-info-popup .park-meta {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.quick-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.quick-stat-card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg p-4 text-center shadow-sm;
|
||||
}
|
||||
|
||||
.quick-stat-value {
|
||||
@apply text-2xl font-bold text-blue-600 dark:text-blue-400;
|
||||
}
|
||||
|
||||
.quick-stat-label {
|
||||
@apply text-sm text-gray-600 dark:text-gray-400 mt-1;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ page_title }}</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Discover theme parks and amusement parks worldwide
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'maps:universal_map' %}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="mr-2 fas fa-globe"></i>All Locations
|
||||
</a>
|
||||
<a href="{% url 'parks:roadtrip_planner' %}"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors">
|
||||
<i class="mr-2 fas fa-route"></i>Plan Road Trip
|
||||
</a>
|
||||
<a href="{% url 'parks:park_list' %}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="mr-2 fas fa-list"></i>List View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="quick-stats mb-6" id="park-stats">
|
||||
<div class="quick-stat-card">
|
||||
<div class="quick-stat-value" id="total-parks">-</div>
|
||||
<div class="quick-stat-label">Total Parks</div>
|
||||
</div>
|
||||
<div class="quick-stat-card">
|
||||
<div class="quick-stat-value" id="operating-parks">-</div>
|
||||
<div class="quick-stat-label">Operating</div>
|
||||
</div>
|
||||
<div class="quick-stat-card">
|
||||
<div class="quick-stat-value" id="countries-count">-</div>
|
||||
<div class="quick-stat-label">Countries</div>
|
||||
</div>
|
||||
<div class="quick-stat-card">
|
||||
<div class="quick-stat-value" id="total-rides">-</div>
|
||||
<div class="quick-stat-label">Total Rides</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters Panel -->
|
||||
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<form id="park-filters"
|
||||
hx-get="{% url 'maps:htmx_filter' %}"
|
||||
hx-trigger="change, submit"
|
||||
hx-target="#map-container"
|
||||
hx-swap="none"
|
||||
hx-push-url="false">
|
||||
|
||||
<!-- Hidden input to specify park-only filtering -->
|
||||
<input type="hidden" name="types" value="park">
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Search -->
|
||||
<div>
|
||||
<label for="search" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Search Parks</label>
|
||||
<input type="text" name="q" id="search"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Search park names..."
|
||||
hx-get="{% url 'maps:htmx_search' %}"
|
||||
hx-trigger="input changed delay:500ms"
|
||||
hx-target="#search-results"
|
||||
hx-indicator="#search-loading">
|
||||
</div>
|
||||
|
||||
<!-- Country -->
|
||||
<div>
|
||||
<label for="country" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Country</label>
|
||||
<input type="text" name="country" id="country"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Filter by country...">
|
||||
</div>
|
||||
|
||||
<!-- State/Region -->
|
||||
<div>
|
||||
<label for="state" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">State/Region</label>
|
||||
<input type="text" name="state" id="state"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Filter by state...">
|
||||
</div>
|
||||
|
||||
<!-- Clustering Toggle -->
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox" name="cluster" value="true" id="cluster-toggle"
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
checked>
|
||||
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">Group Nearby Parks</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Park Status Filters -->
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Park Status</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label class="park-status-badge park-status-operating cursor-pointer">
|
||||
<input type="checkbox" name="park_status" value="OPERATING" class="hidden status-checkbox" checked>
|
||||
<i class="mr-1 fas fa-check-circle"></i>Operating
|
||||
</label>
|
||||
<label class="park-status-badge park-status-closed cursor-pointer">
|
||||
<input type="checkbox" name="park_status" value="CLOSED_TEMP" class="hidden status-checkbox">
|
||||
<i class="mr-1 fas fa-clock"></i>Temporarily Closed
|
||||
</label>
|
||||
<label class="park-status-badge park-status-closed cursor-pointer">
|
||||
<input type="checkbox" name="park_status" value="CLOSED_PERM" class="hidden status-checkbox">
|
||||
<i class="mr-1 fas fa-times-circle"></i>Permanently Closed
|
||||
</label>
|
||||
<label class="park-status-badge park-status-construction cursor-pointer">
|
||||
<input type="checkbox" name="park_status" value="UNDER_CONSTRUCTION" class="hidden status-checkbox">
|
||||
<i class="mr-1 fas fa-hard-hat"></i>Under Construction
|
||||
</label>
|
||||
<label class="park-status-badge park-status-demolished cursor-pointer">
|
||||
<input type="checkbox" name="park_status" value="DEMOLISHED" class="hidden status-checkbox">
|
||||
<i class="mr-1 fas fa-ban"></i>Demolished
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div id="search-results" class="mt-4"></div>
|
||||
<div id="search-loading" class="htmx-indicator">
|
||||
<div class="flex items-center justify-center p-4">
|
||||
<div class="w-6 h-6 border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Searching parks...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Container -->
|
||||
<div class="relative">
|
||||
<div id="map-container" class="map-container"></div>
|
||||
|
||||
<!-- Map Loading Indicator -->
|
||||
<div id="map-loading" class="htmx-indicator absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<div class="text-center">
|
||||
<div class="w-8 h-8 mx-auto mb-4 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Loading park data...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Details Modal -->
|
||||
<div id="location-modal" class="fixed inset-0 z-50 hidden">
|
||||
<!-- Modal content will be loaded here via HTMX -->
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- Leaflet JS -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<!-- Leaflet MarkerCluster JS -->
|
||||
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
|
||||
|
||||
<script>
|
||||
// Park-specific map class
|
||||
class ParkMap {
|
||||
constructor(containerId, options = {}) {
|
||||
this.containerId = containerId;
|
||||
this.options = {
|
||||
center: [39.8283, -98.5795],
|
||||
zoom: 4,
|
||||
enableClustering: true,
|
||||
...options
|
||||
};
|
||||
this.map = null;
|
||||
this.markers = new L.MarkerClusterGroup({
|
||||
maxClusterRadius: 50,
|
||||
spiderfyOnMaxZoom: true,
|
||||
showCoverageOnHover: false
|
||||
});
|
||||
this.currentData = [];
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Initialize the map
|
||||
this.map = L.map(this.containerId, {
|
||||
center: this.options.center,
|
||||
zoom: this.options.zoom,
|
||||
zoomControl: false
|
||||
});
|
||||
|
||||
// Add custom zoom control
|
||||
L.control.zoom({
|
||||
position: 'bottomright'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Add tile layers with dark mode support
|
||||
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
});
|
||||
|
||||
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors, © CARTO'
|
||||
});
|
||||
|
||||
// Set initial tiles based on theme
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
darkTiles.addTo(this.map);
|
||||
} else {
|
||||
lightTiles.addTo(this.map);
|
||||
}
|
||||
|
||||
// Listen for theme changes
|
||||
this.observeThemeChanges(lightTiles, darkTiles);
|
||||
|
||||
// Add markers cluster group
|
||||
this.map.addLayer(this.markers);
|
||||
|
||||
// Bind map events
|
||||
this.bindEvents();
|
||||
|
||||
// Load initial data
|
||||
this.loadMapData();
|
||||
}
|
||||
|
||||
observeThemeChanges(lightTiles, darkTiles) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
this.map.removeLayer(lightTiles);
|
||||
this.map.addLayer(darkTiles);
|
||||
} else {
|
||||
this.map.removeLayer(darkTiles);
|
||||
this.map.addLayer(lightTiles);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Update map when bounds change
|
||||
this.map.on('moveend zoomend', () => {
|
||||
this.updateMapBounds();
|
||||
});
|
||||
|
||||
// Handle filter form changes
|
||||
document.getElementById('park-filters').addEventListener('htmx:afterRequest', (event) => {
|
||||
if (event.detail.successful) {
|
||||
this.loadMapData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadMapData() {
|
||||
try {
|
||||
document.getElementById('map-loading').style.display = 'flex';
|
||||
|
||||
const formData = new FormData(document.getElementById('park-filters'));
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Add form data to params
|
||||
for (let [key, value] of formData.entries()) {
|
||||
params.append(key, value);
|
||||
}
|
||||
|
||||
// Add map bounds
|
||||
const bounds = this.map.getBounds();
|
||||
params.append('north', bounds.getNorth());
|
||||
params.append('south', bounds.getSouth());
|
||||
params.append('east', bounds.getEast());
|
||||
params.append('west', bounds.getWest());
|
||||
params.append('zoom', this.map.getZoom());
|
||||
|
||||
const response = await fetch(`{{ map_api_urls.locations }}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.updateMarkers(data.data);
|
||||
this.updateStats(data.data);
|
||||
} else {
|
||||
console.error('Park data error:', data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load park data:', error);
|
||||
} finally {
|
||||
document.getElementById('map-loading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updateStats(data) {
|
||||
// Update quick stats
|
||||
const totalParks = (data.locations || []).length + (data.clusters || []).reduce((sum, cluster) => sum + cluster.count, 0);
|
||||
const operatingParks = (data.locations || []).filter(park => park.status === 'OPERATING').length;
|
||||
const countries = new Set((data.locations || []).map(park => park.country).filter(Boolean)).size;
|
||||
const totalRides = (data.locations || []).reduce((sum, park) => sum + (park.ride_count || 0), 0);
|
||||
|
||||
document.getElementById('total-parks').textContent = totalParks.toLocaleString();
|
||||
document.getElementById('operating-parks').textContent = operatingParks.toLocaleString();
|
||||
document.getElementById('countries-count').textContent = countries.toLocaleString();
|
||||
document.getElementById('total-rides').textContent = totalRides.toLocaleString();
|
||||
}
|
||||
|
||||
updateMarkers(data) {
|
||||
// Clear existing markers
|
||||
this.markers.clearLayers();
|
||||
|
||||
// Add park markers
|
||||
if (data.locations) {
|
||||
data.locations.forEach(park => {
|
||||
this.addParkMarker(park);
|
||||
});
|
||||
}
|
||||
|
||||
// Add cluster markers
|
||||
if (data.clusters) {
|
||||
data.clusters.forEach(cluster => {
|
||||
this.addClusterMarker(cluster);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addParkMarker(park) {
|
||||
const icon = this.getParkIcon(park.status);
|
||||
const marker = L.marker([park.latitude, park.longitude], { icon });
|
||||
|
||||
// Create popup content
|
||||
const popupContent = this.createParkPopupContent(park);
|
||||
marker.bindPopup(popupContent, { maxWidth: 350 });
|
||||
|
||||
// Add click handler for detailed view
|
||||
marker.on('click', () => {
|
||||
this.showParkDetails(park.id);
|
||||
});
|
||||
|
||||
this.markers.addLayer(marker);
|
||||
}
|
||||
|
||||
addClusterMarker(cluster) {
|
||||
const marker = L.marker([cluster.latitude, cluster.longitude], {
|
||||
icon: L.divIcon({
|
||||
className: 'cluster-marker',
|
||||
html: `<div class="cluster-marker-inner">${cluster.count}</div>`,
|
||||
iconSize: [40, 40]
|
||||
})
|
||||
});
|
||||
|
||||
marker.bindPopup(`${cluster.count} parks in this area`);
|
||||
this.markers.addLayer(marker);
|
||||
}
|
||||
|
||||
getParkIcon(status) {
|
||||
const statusClass = {
|
||||
'OPERATING': 'park-marker-operating',
|
||||
'CLOSED_TEMP': 'park-marker-closed',
|
||||
'CLOSED_PERM': 'park-marker-closed',
|
||||
'UNDER_CONSTRUCTION': 'park-marker-construction',
|
||||
'DEMOLISHED': 'park-marker-demolished'
|
||||
}[status] || 'park-marker-operating';
|
||||
|
||||
return L.divIcon({
|
||||
className: 'park-marker',
|
||||
html: `<div class="park-marker-inner ${statusClass}">🎢</div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
}
|
||||
|
||||
createParkPopupContent(park) {
|
||||
const statusClass = {
|
||||
'OPERATING': 'park-status-operating',
|
||||
'CLOSED_TEMP': 'park-status-closed',
|
||||
'CLOSED_PERM': 'park-status-closed',
|
||||
'UNDER_CONSTRUCTION': 'park-status-construction',
|
||||
'DEMOLISHED': 'park-status-demolished'
|
||||
}[park.status] || 'park-status-operating';
|
||||
|
||||
return `
|
||||
<div class="park-info-popup">
|
||||
<h3>${park.name}</h3>
|
||||
<div class="park-meta">
|
||||
<span class="park-status-badge ${statusClass}">
|
||||
${this.getStatusDisplayName(park.status)}
|
||||
</span>
|
||||
</div>
|
||||
${park.formatted_location ? `<div class="park-meta"><i class="fas fa-map-marker-alt mr-1"></i>${park.formatted_location}</div>` : ''}
|
||||
${park.operator ? `<div class="park-meta"><i class="fas fa-building mr-1"></i>${park.operator}</div>` : ''}
|
||||
${park.ride_count ? `<div class="park-meta"><i class="fas fa-rocket mr-1"></i>${park.ride_count} rides</div>` : ''}
|
||||
${park.average_rating ? `<div class="park-meta"><i class="fas fa-star mr-1"></i>${park.average_rating}/10 rating</div>` : ''}
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button onclick="parkMap.showParkDetails(${park.id})"
|
||||
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
|
||||
View Details
|
||||
</button>
|
||||
<a href="/parks/${park.slug}/"
|
||||
class="px-3 py-1 text-sm text-blue-600 border border-blue-600 rounded hover:bg-blue-50">
|
||||
Visit Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
getStatusDisplayName(status) {
|
||||
const statusMap = {
|
||||
'OPERATING': 'Operating',
|
||||
'CLOSED_TEMP': 'Temporarily Closed',
|
||||
'CLOSED_PERM': 'Permanently Closed',
|
||||
'UNDER_CONSTRUCTION': 'Under Construction',
|
||||
'DEMOLISHED': 'Demolished'
|
||||
};
|
||||
return statusMap[status] || 'Unknown';
|
||||
}
|
||||
|
||||
showParkDetails(parkId) {
|
||||
htmx.ajax('GET', `{% url 'maps:htmx_location_detail' 'park' 0 %}`.replace('0', parkId), {
|
||||
target: '#location-modal',
|
||||
swap: 'innerHTML'
|
||||
}).then(() => {
|
||||
document.getElementById('location-modal').classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
updateMapBounds() {
|
||||
// Reload data when the map moves significantly
|
||||
clearTimeout(this.boundsUpdateTimeout);
|
||||
this.boundsUpdateTimeout = setTimeout(() => {
|
||||
this.loadMapData();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize map when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.parkMap = new ParkMap('map-container', {
|
||||
enableClustering: {{ enable_clustering|yesno:"true,false" }}
|
||||
});
|
||||
|
||||
// Handle status filter toggles
|
||||
document.querySelectorAll('.park-status-badge').forEach(badge => {
|
||||
const checkbox = badge.querySelector('input[type="checkbox"]');
|
||||
|
||||
// Set initial state
|
||||
if (checkbox && checkbox.checked) {
|
||||
badge.style.opacity = '1';
|
||||
} else if (checkbox) {
|
||||
badge.style.opacity = '0.5';
|
||||
}
|
||||
|
||||
badge.addEventListener('click', () => {
|
||||
if (checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
badge.style.opacity = checkbox.checked ? '1' : '0.5';
|
||||
|
||||
// Trigger form change
|
||||
document.getElementById('park-filters').dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Close modal handler
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'location-modal') {
|
||||
document.getElementById('location-modal').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.cluster-marker {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cluster-marker-inner {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.dark .cluster-marker-inner {
|
||||
border-color: #374151;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
432
backend/templates/maps/partials/filter_panel.html
Normal file
432
backend/templates/maps/partials/filter_panel.html
Normal file
@@ -0,0 +1,432 @@
|
||||
<!-- Reusable Filter Panel Component -->
|
||||
<div class="filter-panel {% if panel_classes %}{{ panel_classes }}{% endif %}">
|
||||
<form id="{{ form_id|default:'filters-form' }}"
|
||||
method="get"
|
||||
{% if hx_target %}hx-get="{{ hx_url }}" hx-trigger="{{ hx_trigger|default:'change, submit' }}" hx-target="{{ hx_target }}" hx-swap="{{ hx_swap|default:'none' }}" hx-push-url="false"{% endif %}
|
||||
class="space-y-4">
|
||||
|
||||
<!-- Search Input -->
|
||||
{% if show_search %}
|
||||
<div>
|
||||
<label for="{{ form_id }}-search" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ search_label|default:"Search" }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
name="q"
|
||||
id="{{ form_id }}-search"
|
||||
class="w-full pl-10 pr-4 py-2 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="{{ search_placeholder|default:'Search by name, location, or keyword...' }}"
|
||||
value="{{ request.GET.q|default:'' }}"
|
||||
{% if search_hx_url %}
|
||||
hx-get="{{ search_hx_url }}"
|
||||
hx-trigger="input changed delay:500ms"
|
||||
hx-target="{{ search_hx_target|default:'#search-results' }}"
|
||||
hx-indicator="{{ search_hx_indicator|default:'#search-loading' }}"
|
||||
{% endif %}>
|
||||
<i class="absolute left-3 top-1/2 transform -translate-y-1/2 fas fa-search text-gray-400"></i>
|
||||
{% if search_hx_url %}
|
||||
<div id="{{ search_hx_indicator|default:'search-loading' }}" class="htmx-indicator absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<div class="w-4 h-4 border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if search_hx_url %}
|
||||
<div id="{{ search_hx_target|default:'search-results' }}" class="mt-2"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location Type Filters -->
|
||||
{% if show_location_types %}
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Location Types</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label class="filter-chip {% if 'park' in location_types %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="types" value="park" class="hidden"
|
||||
{% if 'park' in location_types %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-tree"></i>Parks
|
||||
</label>
|
||||
<label class="filter-chip {% if 'ride' in location_types %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="types" value="ride" class="hidden"
|
||||
{% if 'ride' in location_types %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-rocket"></i>Rides
|
||||
</label>
|
||||
<label class="filter-chip {% if 'company' in location_types %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="types" value="company" class="hidden"
|
||||
{% if 'company' in location_types %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-building"></i>Companies
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Park Status Filters -->
|
||||
{% if show_park_status and 'park' in location_types %}
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Park Status</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label class="filter-chip {% if 'OPERATING' in park_statuses %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="park_status" value="OPERATING" class="hidden"
|
||||
{% if 'OPERATING' in park_statuses %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-check-circle"></i>Operating
|
||||
</label>
|
||||
<label class="filter-chip {% if 'CLOSED_TEMP' in park_statuses %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="park_status" value="CLOSED_TEMP" class="hidden"
|
||||
{% if 'CLOSED_TEMP' in park_statuses %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-clock"></i>Temporarily Closed
|
||||
</label>
|
||||
<label class="filter-chip {% if 'CLOSED_PERM' in park_statuses %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="park_status" value="CLOSED_PERM" class="hidden"
|
||||
{% if 'CLOSED_PERM' in park_statuses %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-times-circle"></i>Permanently Closed
|
||||
</label>
|
||||
<label class="filter-chip {% if 'UNDER_CONSTRUCTION' in park_statuses %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="park_status" value="UNDER_CONSTRUCTION" class="hidden"
|
||||
{% if 'UNDER_CONSTRUCTION' in park_statuses %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-hard-hat"></i>Under Construction
|
||||
</label>
|
||||
<label class="filter-chip {% if 'DEMOLISHED' in park_statuses %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="park_status" value="DEMOLISHED" class="hidden"
|
||||
{% if 'DEMOLISHED' in park_statuses %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-ban"></i>Demolished
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location Filters -->
|
||||
{% if show_location_filters %}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-{{ location_filter_columns|default:'3' }}">
|
||||
{% if show_country %}
|
||||
<div>
|
||||
<label for="{{ form_id }}-country" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Country</label>
|
||||
<input type="text"
|
||||
name="country"
|
||||
id="{{ form_id }}-country"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Filter by country..."
|
||||
value="{{ request.GET.country|default:'' }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if show_state %}
|
||||
<div>
|
||||
<label for="{{ form_id }}-state" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">State/Region</label>
|
||||
<input type="text"
|
||||
name="state"
|
||||
id="{{ form_id }}-state"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Filter by state..."
|
||||
value="{{ request.GET.state|default:'' }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if show_city %}
|
||||
<div>
|
||||
<label for="{{ form_id }}-city" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">City</label>
|
||||
<input type="text"
|
||||
name="city"
|
||||
id="{{ form_id }}-city"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Filter by city..."
|
||||
value="{{ request.GET.city|default:'' }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Distance/Radius Filter -->
|
||||
{% if show_radius %}
|
||||
<div>
|
||||
<label for="{{ form_id }}-radius" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ radius_label|default:"Search Radius" }} (miles)
|
||||
</label>
|
||||
<div class="flex items-center space-x-4">
|
||||
<input type="range"
|
||||
name="radius"
|
||||
id="{{ form_id }}-radius"
|
||||
min="{{ radius_min|default:'1' }}"
|
||||
max="{{ radius_max|default:'500' }}"
|
||||
value="{{ request.GET.radius|default:'50' }}"
|
||||
class="flex-1"
|
||||
oninput="document.getElementById('{{ form_id }}-radius-value').textContent = this.value">
|
||||
<span id="{{ form_id }}-radius-value" class="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-16">
|
||||
{{ request.GET.radius|default:'50' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Sorting -->
|
||||
{% if show_sort %}
|
||||
<div>
|
||||
<label for="{{ form_id }}-sort" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Sort By</label>
|
||||
<select name="sort"
|
||||
id="{{ form_id }}-sort"
|
||||
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
{% for value, label in sort_options %}
|
||||
<option value="{{ value }}" {% if request.GET.sort == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% empty %}
|
||||
<option value="name" {% if request.GET.sort == 'name' %}selected{% endif %}>Name (A-Z)</option>
|
||||
<option value="-name" {% if request.GET.sort == '-name' %}selected{% endif %}>Name (Z-A)</option>
|
||||
<option value="location" {% if request.GET.sort == 'location' %}selected{% endif %}>Location</option>
|
||||
<option value="-created_at" {% if request.GET.sort == '-created_at' %}selected{% endif %}>Recently Added</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Map Options -->
|
||||
{% if show_map_options %}
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Map Options</label>
|
||||
<div class="space-y-2">
|
||||
{% if show_clustering_toggle %}
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox"
|
||||
name="cluster"
|
||||
value="true"
|
||||
id="{{ form_id }}-cluster"
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
{% if enable_clustering %}checked{% endif %}>
|
||||
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">Group Nearby Locations</span>
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
{% if show_satellite_toggle %}
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox"
|
||||
name="satellite"
|
||||
value="true"
|
||||
id="{{ form_id }}-satellite"
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700">
|
||||
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">Satellite View</span>
|
||||
</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Custom Filter Sections -->
|
||||
{% if custom_filters %}
|
||||
{% for filter_section in custom_filters %}
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">{{ filter_section.title }}</label>
|
||||
{% if filter_section.type == 'checkboxes' %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for option in filter_section.options %}
|
||||
<label class="filter-chip {% if option.value in filter_section.selected %}active{% else %}inactive{% endif %}">
|
||||
<input type="checkbox" name="{{ filter_section.name }}" value="{{ option.value }}" class="hidden"
|
||||
{% if option.value in filter_section.selected %}checked{% endif %}>
|
||||
{% if option.icon %}<i class="mr-2 {{ option.icon }}"></i>{% endif %}{{ option.label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif filter_section.type == 'select' %}
|
||||
<select name="{{ filter_section.name }}"
|
||||
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
<option value="">{{ filter_section.placeholder|default:"All" }}</option>
|
||||
{% for option in filter_section.options %}
|
||||
<option value="{{ option.value }}" {% if option.value == filter_section.selected %}selected{% endif %}>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% elif filter_section.type == 'range' %}
|
||||
<div class="flex items-center space-x-4">
|
||||
<input type="range"
|
||||
name="{{ filter_section.name }}"
|
||||
min="{{ filter_section.min }}"
|
||||
max="{{ filter_section.max }}"
|
||||
value="{{ filter_section.value }}"
|
||||
class="flex-1"
|
||||
oninput="document.getElementById('{{ filter_section.name }}-value').textContent = this.value">
|
||||
<span id="{{ filter_section.name }}-value" class="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-16">
|
||||
{{ filter_section.value }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-3 pt-2">
|
||||
{% if show_submit_button %}
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<i class="mr-2 fas fa-search"></i>{{ submit_text|default:"Apply Filters" }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if show_clear_button %}
|
||||
<a href="{{ clear_url|default:request.path }}"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="mr-2 fas fa-times"></i>{{ clear_text|default:"Clear All" }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Custom Action Buttons -->
|
||||
{% if custom_actions %}
|
||||
{% for action in custom_actions %}
|
||||
<a href="{{ action.url }}"
|
||||
class="px-4 py-2 text-sm font-medium {{ action.classes|default:'text-gray-700 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600' }} rounded-lg transition-colors">
|
||||
{% if action.icon %}<i class="mr-2 {{ action.icon }}"></i>{% endif %}{{ action.text }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Filter Panel Styles -->
|
||||
<style>
|
||||
.filter-panel {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg shadow p-4;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
@apply inline-flex items-center px-3 py-1 rounded-full text-sm font-medium cursor-pointer transition-all;
|
||||
}
|
||||
|
||||
.filter-chip.active {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100;
|
||||
}
|
||||
|
||||
.filter-chip.inactive {
|
||||
@apply bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Custom range slider styling */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-track {
|
||||
background: #e5e7eb;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #3b82f6;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-track {
|
||||
background: #e5e7eb;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
background: #3b82f6;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dark input[type="range"]::-webkit-slider-track {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.dark input[type="range"]::-moz-range-track {
|
||||
background: #4b5563;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Filter Panel JavaScript -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const formId = '{{ form_id|default:"filters-form" }}';
|
||||
const form = document.getElementById(formId);
|
||||
|
||||
if (!form) return;
|
||||
|
||||
// Handle filter chip toggles
|
||||
form.querySelectorAll('.filter-chip').forEach(chip => {
|
||||
const checkbox = chip.querySelector('input[type="checkbox"]');
|
||||
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
chip.classList.toggle('active', checkbox.checked);
|
||||
chip.classList.toggle('inactive', !checkbox.checked);
|
||||
|
||||
// Trigger form change for HTMX
|
||||
form.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-submit form on most changes (except search input)
|
||||
form.addEventListener('change', function(e) {
|
||||
if (e.target.name !== 'q' && !e.target.closest('.no-auto-submit')) {
|
||||
{% if hx_target %}
|
||||
// HTMX will handle the submission
|
||||
{% else %}
|
||||
this.submit();
|
||||
{% endif %}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle search input separately with debouncing
|
||||
const searchInput = form.querySelector('input[name="q"]');
|
||||
if (searchInput) {
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
{% if not search_hx_url %}
|
||||
form.dispatchEvent(new Event('change'));
|
||||
{% endif %}
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
// Custom event for filter updates
|
||||
form.addEventListener('filtersUpdated', function(e) {
|
||||
// Custom logic when filters are updated
|
||||
console.log('Filters updated:', e.detail);
|
||||
});
|
||||
|
||||
// Emit initial filter state
|
||||
window.addEventListener('load', function() {
|
||||
const formData = new FormData(form);
|
||||
const filters = {};
|
||||
for (let [key, value] of formData.entries()) {
|
||||
if (filters[key]) {
|
||||
if (Array.isArray(filters[key])) {
|
||||
filters[key].push(value);
|
||||
} else {
|
||||
filters[key] = [filters[key], value];
|
||||
}
|
||||
} else {
|
||||
filters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const event = new CustomEvent('filtersInitialized', {
|
||||
detail: filters
|
||||
});
|
||||
form.dispatchEvent(event);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
346
backend/templates/maps/partials/location_card.html
Normal file
346
backend/templates/maps/partials/location_card.html
Normal file
@@ -0,0 +1,346 @@
|
||||
<!-- Reusable Location Card Component -->
|
||||
<div class="location-card {% if card_classes %}{{ card_classes }}{% endif %}"
|
||||
{% if location.id %}data-location-id="{{ location.id }}"{% endif %}
|
||||
{% if location.type %}data-location-type="{{ location.type }}"{% endif %}
|
||||
{% if location.latitude and location.longitude %}data-lat="{{ location.latitude }}" data-lng="{{ location.longitude }}"{% endif %}
|
||||
{% if clickable %}onclick="{{ onclick_action|default:'window.location.href=\''|add:location.get_absolute_url|add:'\'' }}"{% endif %}>
|
||||
|
||||
<!-- Card Header -->
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate">
|
||||
{% if location.name %}
|
||||
{{ location.name }}
|
||||
{% else %}
|
||||
Unknown Location
|
||||
{% endif %}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||
{{ location.formatted_location|default:"Location not specified" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 ml-3">
|
||||
<span class="location-type-badge location-type-{{ location.type|default:'unknown' }}">
|
||||
{% if location.type == 'park' %}
|
||||
<i class="mr-1 fas fa-tree"></i>Park
|
||||
{% elif location.type == 'ride' %}
|
||||
<i class="mr-1 fas fa-rocket"></i>Ride
|
||||
{% elif location.type == 'company' %}
|
||||
<i class="mr-1 fas fa-building"></i>Company
|
||||
{% else %}
|
||||
<i class="mr-1 fas fa-map-marker-alt"></i>Location
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Distance Badge (if applicable) -->
|
||||
{% if location.distance %}
|
||||
<div class="mb-3">
|
||||
<span class="distance-badge">
|
||||
<i class="mr-1 fas fa-route"></i>{{ location.distance|floatformat:1 }} miles away
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Type-specific Content -->
|
||||
{% if location.type == 'park' %}
|
||||
{% include 'maps/partials/park_card_content.html' with park=location %}
|
||||
{% elif location.type == 'ride' %}
|
||||
{% include 'maps/partials/ride_card_content.html' with ride=location %}
|
||||
{% elif location.type == 'company' %}
|
||||
{% include 'maps/partials/company_card_content.html' with company=location %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
{% if show_actions %}
|
||||
<div class="flex gap-2 mt-4">
|
||||
<a href="{{ location.get_absolute_url }}"
|
||||
class="flex-1 px-3 py-2 text-sm text-center text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
{{ primary_action_text|default:"View Details" }}
|
||||
</a>
|
||||
|
||||
{% if location.latitude and location.longitude %}
|
||||
<a href="{% url 'maps:nearby_locations' %}?lat={{ location.latitude }}&lng={{ location.longitude }}&radius=25"
|
||||
class="px-3 py-2 text-sm text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors"
|
||||
title="Find nearby locations">
|
||||
<i class="fas fa-search-location"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if show_map_action %}
|
||||
<button onclick="showOnMap('{{ location.type }}', {{ location.id }})"
|
||||
class="px-3 py-2 text-sm text-green-600 border border-green-600 rounded-lg hover:bg-green-50 dark:hover:bg-green-900 transition-colors"
|
||||
title="Show on map">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if show_trip_action %}
|
||||
<button onclick="addToTrip({{ location|safe }})"
|
||||
class="px-3 py-2 text-sm text-purple-600 border border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900 transition-colors"
|
||||
title="Add to trip">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Card Content Partials -->
|
||||
|
||||
<!-- Park Card Content -->
|
||||
{% comment %}
|
||||
This would be in templates/maps/partials/park_card_content.html
|
||||
{% endcomment %}
|
||||
<script type="text/template" id="park-card-content-template">
|
||||
<div class="space-y-2">
|
||||
{% if park.status %}
|
||||
<div class="flex items-center">
|
||||
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction{% else %}status-demolished{% endif %}">
|
||||
{% if park.status == 'OPERATING' %}
|
||||
<i class="mr-1 fas fa-check-circle"></i>Operating
|
||||
{% elif park.status == 'CLOSED_TEMP' %}
|
||||
<i class="mr-1 fas fa-clock"></i>Temporarily Closed
|
||||
{% elif park.status == 'CLOSED_PERM' %}
|
||||
<i class="mr-1 fas fa-times-circle"></i>Permanently Closed
|
||||
{% elif park.status == 'UNDER_CONSTRUCTION' %}
|
||||
<i class="mr-1 fas fa-hard-hat"></i>Under Construction
|
||||
{% elif park.status == 'DEMOLISHED' %}
|
||||
<i class="mr-1 fas fa-ban"></i>Demolished
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if park.operator %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-building"></i>
|
||||
<span>{{ park.operator }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if park.ride_count %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-rocket"></i>
|
||||
<span>{{ park.ride_count }} ride{{ park.ride_count|pluralize }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if park.average_rating %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-star text-yellow-500"></i>
|
||||
<span>{{ park.average_rating|floatformat:1 }}/10</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if park.opening_date %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-calendar"></i>
|
||||
<span>Opened {{ park.opening_date.year }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<!-- Ride Card Content -->
|
||||
<script type="text/template" id="ride-card-content-template">
|
||||
<div class="space-y-2">
|
||||
{% if ride.park_name %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-tree"></i>
|
||||
<span>{{ ride.park_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.manufacturer %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-industry"></i>
|
||||
<span>{{ ride.manufacturer }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.designer %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-drafting-compass"></i>
|
||||
<span>{{ ride.designer }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.opening_date %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-calendar"></i>
|
||||
<span>Opened {{ ride.opening_date.year }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.status %}
|
||||
<div class="flex items-center">
|
||||
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating{% elif ride.status == 'CLOSED' %}status-closed{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction{% else %}status-demolished{% endif %}">
|
||||
{% if ride.status == 'OPERATING' %}
|
||||
<i class="mr-1 fas fa-check-circle"></i>Operating
|
||||
{% elif ride.status == 'CLOSED' %}
|
||||
<i class="mr-1 fas fa-times-circle"></i>Closed
|
||||
{% elif ride.status == 'UNDER_CONSTRUCTION' %}
|
||||
<i class="mr-1 fas fa-hard-hat"></i>Under Construction
|
||||
{% elif ride.status == 'DEMOLISHED' %}
|
||||
<i class="mr-1 fas fa-ban"></i>Demolished
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<!-- Company Card Content -->
|
||||
<script type="text/template" id="company-card-content-template">
|
||||
<div class="space-y-2">
|
||||
{% if company.company_type %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-tag"></i>
|
||||
<span>{{ company.get_company_type_display }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if company.founded_year %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-calendar"></i>
|
||||
<span>Founded {{ company.founded_year }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if company.website %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-globe"></i>
|
||||
<a href="{{ company.website }}" target="_blank" class="text-blue-600 hover:text-blue-700 dark:text-blue-400">
|
||||
Visit Website
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if company.parks_count %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-tree"></i>
|
||||
<span>{{ company.parks_count }} park{{ company.parks_count|pluralize }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if company.rides_count %}
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-rocket"></i>
|
||||
<span>{{ company.rides_count }} ride{{ company.rides_count|pluralize }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<!-- Location Card Styles -->
|
||||
<style>
|
||||
.location-card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm hover:shadow-md transition-all border border-gray-200 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.location-card:hover {
|
||||
@apply border-blue-300 dark:border-blue-600 shadow-lg;
|
||||
}
|
||||
|
||||
.location-card.selected {
|
||||
@apply ring-2 ring-blue-500 border-blue-500;
|
||||
}
|
||||
|
||||
.location-card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.location-type-badge {
|
||||
@apply inline-flex items-center px-2 py-1 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.location-type-park {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100;
|
||||
}
|
||||
|
||||
.location-type-ride {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100;
|
||||
}
|
||||
|
||||
.location-type-company {
|
||||
@apply bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100;
|
||||
}
|
||||
|
||||
.location-type-unknown {
|
||||
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100;
|
||||
}
|
||||
|
||||
.distance-badge {
|
||||
@apply inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@apply inline-flex items-center px-2 py-1 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.status-operating {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100;
|
||||
}
|
||||
|
||||
.status-closed {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100;
|
||||
}
|
||||
|
||||
.status-construction {
|
||||
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100;
|
||||
}
|
||||
|
||||
.status-demolished {
|
||||
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Location Card JavaScript -->
|
||||
<script>
|
||||
// Global functions for location card actions
|
||||
window.showOnMap = function(type, id) {
|
||||
// Emit custom event for map integration
|
||||
const event = new CustomEvent('showLocationOnMap', {
|
||||
detail: { type, id }
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
};
|
||||
|
||||
window.addToTrip = function(locationData) {
|
||||
// Emit custom event for trip integration
|
||||
const event = new CustomEvent('addLocationToTrip', {
|
||||
detail: locationData
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
};
|
||||
|
||||
// Handle location card selection
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('click', function(e) {
|
||||
const card = e.target.closest('.location-card');
|
||||
if (card && card.dataset.locationId) {
|
||||
// Remove previous selections
|
||||
document.querySelectorAll('.location-card.selected').forEach(c => {
|
||||
c.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Add selection to clicked card
|
||||
card.classList.add('selected');
|
||||
|
||||
// Emit selection event
|
||||
const event = new CustomEvent('locationCardSelected', {
|
||||
detail: {
|
||||
id: card.dataset.locationId,
|
||||
type: card.dataset.locationType,
|
||||
lat: card.dataset.lat,
|
||||
lng: card.dataset.lng,
|
||||
element: card
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
530
backend/templates/maps/partials/location_popup.html
Normal file
530
backend/templates/maps/partials/location_popup.html
Normal file
@@ -0,0 +1,530 @@
|
||||
<!-- Reusable Location Popup Component for Maps -->
|
||||
<div class="location-popup {% if popup_classes %}{{ popup_classes }}{% endif %}"
|
||||
data-location-id="{{ location.id }}"
|
||||
data-location-type="{{ location.type }}">
|
||||
|
||||
<!-- Popup Header -->
|
||||
<div class="popup-header">
|
||||
<h3 class="popup-title">{{ location.name|default:"Unknown Location" }}</h3>
|
||||
{% if location.type %}
|
||||
<span class="popup-type-badge popup-type-{{ location.type }}">
|
||||
{% if location.type == 'park' %}
|
||||
<i class="mr-1 fas fa-tree"></i>Park
|
||||
{% elif location.type == 'ride' %}
|
||||
<i class="mr-1 fas fa-rocket"></i>Ride
|
||||
{% elif location.type == 'company' %}
|
||||
<i class="mr-1 fas fa-building"></i>Company
|
||||
{% else %}
|
||||
<i class="mr-1 fas fa-map-marker-alt"></i>Location
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Location Information -->
|
||||
{% if location.formatted_location %}
|
||||
<div class="popup-meta">
|
||||
<i class="fas fa-map-marker-alt mr-1"></i>{{ location.formatted_location }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Distance (if applicable) -->
|
||||
{% if location.distance %}
|
||||
<div class="popup-meta">
|
||||
<i class="fas fa-route mr-1"></i>{{ location.distance|floatformat:1 }} miles away
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Type-specific Content -->
|
||||
{% if location.type == 'park' %}
|
||||
<!-- Park-specific popup content -->
|
||||
{% if location.status %}
|
||||
<div class="popup-meta">
|
||||
<span class="popup-status-badge popup-status-{% if location.status == 'OPERATING' %}operating{% elif location.status == 'CLOSED_TEMP' or location.status == 'CLOSED_PERM' %}closed{% elif location.status == 'UNDER_CONSTRUCTION' %}construction{% else %}demolished{% endif %}">
|
||||
{% if location.status == 'OPERATING' %}
|
||||
<i class="mr-1 fas fa-check-circle"></i>Operating
|
||||
{% elif location.status == 'CLOSED_TEMP' %}
|
||||
<i class="mr-1 fas fa-clock"></i>Temporarily Closed
|
||||
{% elif location.status == 'CLOSED_PERM' %}
|
||||
<i class="mr-1 fas fa-times-circle"></i>Permanently Closed
|
||||
{% elif location.status == 'UNDER_CONSTRUCTION' %}
|
||||
<i class="mr-1 fas fa-hard-hat"></i>Under Construction
|
||||
{% elif location.status == 'DEMOLISHED' %}
|
||||
<i class="mr-1 fas fa-ban"></i>Demolished
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if location.operator %}
|
||||
<div class="popup-meta">
|
||||
<i class="fas fa-building mr-1"></i>{{ location.operator }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if location.ride_count %}
|
||||
<div class="popup-meta">
|
||||
<i class="fas fa-rocket mr-1"></i>{{ location.ride_count }} ride{{ location.ride_count|pluralize }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if location.average_rating %}
|
||||
<div class="popup-meta">
|
||||
<i class="fas fa-star mr-1 text-yellow-500"></i>{{ location.average_rating|floatformat:1 }}/10 rating
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if location.opening_date %}
|
||||
<div class="popup-meta">
|
||||
<i class="fas fa-calendar mr-1"></i>Opened {{ location.opening_date.year }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% elif location.type == 'ride' %}
|
||||
<!-- Ride-specific popup content -->
|
||||
{% if location.park_name %}
|
||||
<div class="popup-meta">
|
||||
<i class="fas fa-tree mr-1"></i>{{ location.park_name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if location.manufacturer %}
|
||||
<div class="popup-meta">
|
||||
<i class="fas fa-industry mr-1"></i>{{ location.manufacturer }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if location.designer %}
|
||||
<div class="popup-meta">
|
||||
<i class="fas fa-drafting-compass mr-1"></i>{{ location.designer }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if location.opening_date %}
|
||||
<div class="popup-meta">
|
||||
<i class="fas fa-calendar mr-1"></i>Opened {{ location.opening_date.year }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if location.status %}
|
||||
<div class="popup-meta">
|
||||
<span class="popup-status-badge popup-status-{% if location.status == 'OPERATING' %}operating{% elif location.status == 'CLOSED' %}closed{% elif location.status == 'UNDER_CONSTRUCTION' %}construction{% else %}demolished{% endif %}">
|
||||
{% if location.status == 'OPERATING' %}
|
||||
<i class="mr-1 fas fa-check-circle"></i>Operating
|
||||
{% elif location.status == 'CLOSED' %}
|
||||
<i class="mr-1 fas fa-times-circle"></i>Closed
|
||||
{% elif location.status == 'UNDER_CONSTRUCTION' %}
|
||||
<i class="mr-1 fas fa-hard-hat"></i>Under Construction
|
||||
{% elif location.status == 'DEMOLISHED' %}
|
||||
<i class="mr-1 fas fa-ban"></i>Demolished
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% elif location.type == 'company' %}
|
||||
<!-- Company-specific popup content -->
|
||||
{% if location.company_type %}
|
||||
<div class="popup-meta">
|
||||
<i class="fas fa-tag mr-1"></i>{{ location.get_company_type_display }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if location.founded_year %}
|
||||
<div class="popup-meta">
|
||||
<i class="fas fa-calendar mr-1"></i>Founded {{ location.founded_year }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if location.parks_count %}
|
||||
<div class="popup-meta">
|
||||
<i class="fas fa-tree mr-1"></i>{{ location.parks_count }} park{{ location.parks_count|pluralize }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if location.rides_count %}
|
||||
<div class="popup-meta">
|
||||
<i class="fas fa-rocket mr-1"></i>{{ location.rides_count }} ride{{ location.rides_count|pluralize }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Custom Content -->
|
||||
{% if custom_content %}
|
||||
<div class="popup-custom">
|
||||
{{ custom_content|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="popup-actions">
|
||||
{% if show_details_button %}
|
||||
<a href="{{ location.get_absolute_url }}"
|
||||
class="popup-btn popup-btn-primary">
|
||||
<i class="mr-1 fas fa-info-circle"></i>{{ details_button_text|default:"View Details" }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if show_nearby_button and location.latitude and location.longitude %}
|
||||
<a href="{% url 'maps:nearby_locations' %}?lat={{ location.latitude }}&lng={{ location.longitude }}&radius=25"
|
||||
class="popup-btn popup-btn-secondary">
|
||||
<i class="mr-1 fas fa-search-location"></i>{{ nearby_button_text|default:"Find Nearby" }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if show_directions_button and location.latitude and location.longitude %}
|
||||
<button onclick="getDirections({{ location.latitude }}, {{ location.longitude }})"
|
||||
class="popup-btn popup-btn-secondary">
|
||||
<i class="mr-1 fas fa-directions"></i>{{ directions_button_text|default:"Directions" }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if show_trip_button %}
|
||||
<button onclick="addLocationToTrip({{ location|safe }})"
|
||||
class="popup-btn popup-btn-accent">
|
||||
<i class="mr-1 fas fa-plus"></i>{{ trip_button_text|default:"Add to Trip" }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if show_share_button %}
|
||||
<button onclick="shareLocation('{{ location.type }}', {{ location.id }})"
|
||||
class="popup-btn popup-btn-secondary">
|
||||
<i class="mr-1 fas fa-share"></i>{{ share_button_text|default:"Share" }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<!-- Custom Action Buttons -->
|
||||
{% if custom_actions %}
|
||||
{% for action in custom_actions %}
|
||||
<{{ action.tag|default:"button" }}
|
||||
{% if action.href %}href="{{ action.href }}"{% endif %}
|
||||
{% if action.onclick %}onclick="{{ action.onclick }}"{% endif %}
|
||||
class="popup-btn {{ action.classes|default:'popup-btn-secondary' }}">
|
||||
{% if action.icon %}<i class="mr-1 {{ action.icon }}"></i>{% endif %}{{ action.text }}
|
||||
</{{ action.tag|default:"button" }}>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Popup Styles -->
|
||||
<style>
|
||||
.location-popup {
|
||||
max-width: 350px;
|
||||
min-width: 250px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1.3;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.popup-type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.popup-type-park {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.popup-type-ride {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.popup-type-company {
|
||||
background-color: #ede9fe;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.popup-meta {
|
||||
margin: 0.375rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.popup-meta i {
|
||||
width: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.popup-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.popup-status-operating {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.popup-status-closed {
|
||||
background-color: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.popup-status-construction {
|
||||
background-color: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.popup-status-demolished {
|
||||
background-color: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.popup-custom {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.popup-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.popup-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.popup-btn-primary {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.popup-btn-primary:hover {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.popup-btn-secondary {
|
||||
background-color: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.popup-btn-secondary:hover {
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.popup-btn-accent {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.popup-btn-accent:hover {
|
||||
background-color: #059669;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.popup-title {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.popup-meta {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.popup-type-park {
|
||||
background-color: #166534;
|
||||
color: #dcfce7;
|
||||
}
|
||||
|
||||
.popup-type-ride {
|
||||
background-color: #1e40af;
|
||||
color: #dbeafe;
|
||||
}
|
||||
|
||||
.popup-type-company {
|
||||
background-color: #7c3aed;
|
||||
color: #ede9fe;
|
||||
}
|
||||
|
||||
.popup-status-operating {
|
||||
background-color: #166534;
|
||||
color: #dcfce7;
|
||||
}
|
||||
|
||||
.popup-status-closed {
|
||||
background-color: #dc2626;
|
||||
color: #fee2e2;
|
||||
}
|
||||
|
||||
.popup-status-construction {
|
||||
background-color: #d97706;
|
||||
color: #fef3c7;
|
||||
}
|
||||
|
||||
.popup-status-demolished {
|
||||
background-color: #6b7280;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.popup-btn-secondary {
|
||||
background-color: #374151;
|
||||
color: #f3f4f6;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.popup-btn-secondary:hover {
|
||||
background-color: #4b5563;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.location-popup {
|
||||
max-width: 280px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.popup-btn {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Popup JavaScript Functions -->
|
||||
<script>
|
||||
// Global functions for popup actions
|
||||
window.getDirections = function(lat, lng) {
|
||||
// Open directions in user's preferred map app
|
||||
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
|
||||
if (isMobile) {
|
||||
// Try to open in native maps app
|
||||
window.open(`geo:${lat},${lng}`, '_blank');
|
||||
} else {
|
||||
// Open in Google Maps
|
||||
window.open(`https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
window.addLocationToTrip = function(locationData) {
|
||||
// Emit custom event for trip integration
|
||||
const event = new CustomEvent('addLocationToTrip', {
|
||||
detail: locationData
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
// Show feedback
|
||||
showPopupFeedback('Added to trip!', 'success');
|
||||
};
|
||||
|
||||
window.shareLocation = function(type, id) {
|
||||
// Share location URL
|
||||
const url = window.location.origin + `/{{ type }}/${id}/`;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: 'Check out this location on ThrillWiki',
|
||||
url: url
|
||||
});
|
||||
} else {
|
||||
// Fallback: copy to clipboard
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
showPopupFeedback('Link copied to clipboard!', 'success');
|
||||
}).catch(() => {
|
||||
showPopupFeedback('Could not copy link', 'error');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.showPopupFeedback = function(message, type = 'info') {
|
||||
// Create temporary feedback element
|
||||
const feedback = document.createElement('div');
|
||||
feedback.className = `popup-feedback popup-feedback-${type}`;
|
||||
feedback.textContent = message;
|
||||
feedback.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: ${type === 'success' ? '#10b981' : type === 'error' ? '#ef4444' : '#3b82f6'};
|
||||
color: white;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
animation: slideIn 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(feedback);
|
||||
|
||||
// Remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
feedback.style.animation = 'slideOut 0.3s ease';
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(feedback);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// Add CSS animations
|
||||
if (!document.getElementById('popup-animations')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'popup-animations';
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
</script>
|
||||
196
backend/templates/maps/partials/map_container.html
Normal file
196
backend/templates/maps/partials/map_container.html
Normal file
@@ -0,0 +1,196 @@
|
||||
<!-- Reusable Map Container Component -->
|
||||
<div class="relative">
|
||||
<div id="{{ map_id|default:'map-container' }}"
|
||||
class="map-container {% if map_classes %}{{ map_classes }}{% endif %}"
|
||||
style="{% if map_height %}height: {{ map_height }};{% endif %}">
|
||||
</div>
|
||||
|
||||
<!-- Map Loading Indicator -->
|
||||
<div id="{{ map_id|default:'map-container' }}-loading"
|
||||
class="htmx-indicator absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<div class="text-center">
|
||||
<div class="w-8 h-8 mx-auto mb-4 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ loading_text|default:"Loading map data..." }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Controls Overlay -->
|
||||
{% if show_controls %}
|
||||
<div class="absolute top-4 right-4 z-10 space-y-2">
|
||||
{% if show_fullscreen %}
|
||||
<button id="{{ map_id|default:'map-container' }}-fullscreen"
|
||||
class="p-2 bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow"
|
||||
title="Toggle Fullscreen">
|
||||
<i class="fas fa-expand text-gray-600 dark:text-gray-400"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if show_layers %}
|
||||
<button id="{{ map_id|default:'map-container' }}-layers"
|
||||
class="p-2 bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow"
|
||||
title="Map Layers">
|
||||
<i class="fas fa-layer-group text-gray-600 dark:text-gray-400"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if show_locate %}
|
||||
<button id="{{ map_id|default:'map-container' }}-locate"
|
||||
class="p-2 bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow"
|
||||
title="Find My Location">
|
||||
<i class="fas fa-crosshairs text-gray-600 dark:text-gray-400"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Map Legend -->
|
||||
{% if show_legend %}
|
||||
<div class="absolute bottom-4 left-4 z-10">
|
||||
<div class="p-3 bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Legend</h4>
|
||||
<div class="space-y-1 text-xs">
|
||||
{% if legend_items %}
|
||||
{% for item in legend_items %}
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 mr-2 rounded-full" style="background-color: {{ item.color }};"></div>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ item.label }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 mr-2 rounded-full bg-green-500"></div>
|
||||
<span class="text-gray-700 dark:text-gray-300">Operating Parks</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 mr-2 rounded-full bg-blue-500"></div>
|
||||
<span class="text-gray-700 dark:text-gray-300">Rides</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 mr-2 rounded-full bg-purple-500"></div>
|
||||
<span class="text-gray-700 dark:text-gray-300">Companies</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 mr-2 rounded-full bg-red-500"></div>
|
||||
<span class="text-gray-700 dark:text-gray-300">Closed/Demolished</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Map Container Styles -->
|
||||
<style>
|
||||
.map-container {
|
||||
height: {{ map_height|default:'60vh' }};
|
||||
min-height: {{ min_height|default:'400px' }};
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.map-container.fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
border-radius: 0;
|
||||
height: 100vh !important;
|
||||
min-height: 100vh !important;
|
||||
}
|
||||
|
||||
.map-container.fullscreen + .absolute {
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
.dark .map-container {
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Map Container JavaScript -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const mapId = '{{ map_id|default:"map-container" }}';
|
||||
const mapContainer = document.getElementById(mapId);
|
||||
|
||||
{% if show_fullscreen %}
|
||||
// Fullscreen toggle
|
||||
const fullscreenBtn = document.getElementById(mapId + '-fullscreen');
|
||||
if (fullscreenBtn) {
|
||||
fullscreenBtn.addEventListener('click', function() {
|
||||
const icon = this.querySelector('i');
|
||||
|
||||
if (mapContainer.classList.contains('fullscreen')) {
|
||||
mapContainer.classList.remove('fullscreen');
|
||||
icon.className = 'fas fa-expand text-gray-600 dark:text-gray-400';
|
||||
this.title = 'Toggle Fullscreen';
|
||||
} else {
|
||||
mapContainer.classList.add('fullscreen');
|
||||
icon.className = 'fas fa-compress text-gray-600 dark:text-gray-400';
|
||||
this.title = 'Exit Fullscreen';
|
||||
}
|
||||
|
||||
// Trigger map resize if map instance exists
|
||||
if (window[mapId + 'Instance']) {
|
||||
setTimeout(() => {
|
||||
window[mapId + 'Instance'].invalidateSize();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{% if show_locate %}
|
||||
// Geolocation
|
||||
const locateBtn = document.getElementById(mapId + '-locate');
|
||||
if (locateBtn && navigator.geolocation) {
|
||||
locateBtn.addEventListener('click', function() {
|
||||
const icon = this.querySelector('i');
|
||||
icon.className = 'fas fa-spinner fa-spin text-gray-600 dark:text-gray-400';
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
function(position) {
|
||||
icon.className = 'fas fa-crosshairs text-gray-600 dark:text-gray-400';
|
||||
|
||||
// Trigger custom event with user location
|
||||
const event = new CustomEvent('userLocationFound', {
|
||||
detail: {
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy
|
||||
}
|
||||
});
|
||||
mapContainer.dispatchEvent(event);
|
||||
},
|
||||
function(error) {
|
||||
icon.className = 'fas fa-crosshairs text-red-500';
|
||||
console.error('Geolocation error:', error);
|
||||
|
||||
// Reset icon after delay
|
||||
setTimeout(() => {
|
||||
icon.className = 'fas fa-crosshairs text-gray-600 dark:text-gray-400';
|
||||
}, 2000);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
// Escape key handler for fullscreen
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && mapContainer.classList.contains('fullscreen')) {
|
||||
const fullscreenBtn = document.getElementById(mapId + '-fullscreen');
|
||||
if (fullscreenBtn) {
|
||||
fullscreenBtn.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
504
backend/templates/maps/universal_map.html
Normal file
504
backend/templates/maps/universal_map.html
Normal file
@@ -0,0 +1,504 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ page_title }} - ThrillWiki{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<!-- Leaflet MarkerCluster CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" />
|
||||
|
||||
<style>
|
||||
.map-container {
|
||||
height: 70vh;
|
||||
min-height: 500px;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark .map-controls {
|
||||
background: rgba(31, 41, 55, 0.95);
|
||||
}
|
||||
|
||||
.filter-pill {
|
||||
@apply inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 cursor-pointer transition-colors;
|
||||
}
|
||||
|
||||
.filter-pill.active {
|
||||
@apply bg-blue-500 text-white dark:bg-blue-600;
|
||||
}
|
||||
|
||||
.filter-pill:hover {
|
||||
@apply bg-gray-200 dark:bg-gray-600;
|
||||
}
|
||||
|
||||
.filter-pill.active:hover {
|
||||
@apply bg-blue-600 dark:bg-blue-700;
|
||||
}
|
||||
|
||||
.location-info-popup {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.location-info-popup h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.location-info-popup p {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.dark .location-info-popup p {
|
||||
color: #ccc;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ page_title }}</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Explore theme parks, rides, and attractions from around the world
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'maps:park_map' %}"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<i class="mr-2 fas fa-map-marker-alt"></i>Parks Only
|
||||
</a>
|
||||
<a href="{% url 'maps:location_list' %}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="mr-2 fas fa-list"></i>List View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters Panel -->
|
||||
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<form id="map-filters"
|
||||
hx-get="{% url 'maps:htmx_filter' %}"
|
||||
hx-trigger="change, submit"
|
||||
hx-target="#map-container"
|
||||
hx-swap="none"
|
||||
hx-push-url="false">
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Search -->
|
||||
<div>
|
||||
<label for="search" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||
<input type="text" name="q" id="search"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Search locations..."
|
||||
hx-get="{% url 'maps:htmx_search' %}"
|
||||
hx-trigger="input changed delay:500ms"
|
||||
hx-target="#search-results"
|
||||
hx-indicator="#search-loading">
|
||||
</div>
|
||||
|
||||
<!-- Country -->
|
||||
<div>
|
||||
<label for="country" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Country</label>
|
||||
<input type="text" name="country" id="country"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Filter by country...">
|
||||
</div>
|
||||
|
||||
<!-- State/Region -->
|
||||
<div>
|
||||
<label for="state" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">State/Region</label>
|
||||
<input type="text" name="state" id="state"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Filter by state...">
|
||||
</div>
|
||||
|
||||
<!-- Clustering Toggle -->
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox" name="cluster" value="true" id="cluster-toggle"
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
checked>
|
||||
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">Enable Clustering</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Type Filters -->
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Location Types</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for type in location_types %}
|
||||
<label class="filter-pill" data-type="{{ type }}">
|
||||
<input type="checkbox" name="types" value="{{ type }}"
|
||||
class="hidden location-type-checkbox"
|
||||
{% if type in initial_location_types %}checked{% endif %}>
|
||||
<i class="mr-2 fas fa-{% if type == 'park' %}map-marker-alt{% elif type == 'ride' %}rocket{% elif type == 'company' %}building{% else %}map-pin{% endif %}"></i>
|
||||
{{ type|title }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div id="search-results" class="mt-4"></div>
|
||||
<div id="search-loading" class="htmx-indicator">
|
||||
<div class="flex items-center justify-center p-4">
|
||||
<div class="w-6 h-6 border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Searching...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Container -->
|
||||
<div class="relative">
|
||||
<div id="map-container" class="map-container"></div>
|
||||
|
||||
<!-- Map Loading Indicator -->
|
||||
<div id="map-loading" class="htmx-indicator absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<div class="text-center">
|
||||
<div class="w-8 h-8 mx-auto mb-4 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Loading map data...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Details Modal -->
|
||||
<div id="location-modal" class="fixed inset-0 z-50 hidden">
|
||||
<!-- Modal content will be loaded here via HTMX -->
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- Leaflet JS -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<!-- Leaflet MarkerCluster JS -->
|
||||
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
|
||||
|
||||
<script>
|
||||
// Map initialization and management
|
||||
class ThrillWikiMap {
|
||||
constructor(containerId, options = {}) {
|
||||
this.containerId = containerId;
|
||||
this.options = {
|
||||
center: [39.8283, -98.5795], // Center of USA
|
||||
zoom: 4,
|
||||
enableClustering: true,
|
||||
...options
|
||||
};
|
||||
this.map = null;
|
||||
this.markers = new L.MarkerClusterGroup();
|
||||
this.currentData = [];
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Initialize the map
|
||||
this.map = L.map(this.containerId, {
|
||||
center: this.options.center,
|
||||
zoom: this.options.zoom,
|
||||
zoomControl: false
|
||||
});
|
||||
|
||||
// Add custom zoom control
|
||||
L.control.zoom({
|
||||
position: 'bottomright'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Add tile layers with dark mode support
|
||||
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
className: 'map-tiles'
|
||||
});
|
||||
|
||||
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors, © CARTO',
|
||||
className: 'map-tiles-dark'
|
||||
});
|
||||
|
||||
// Set initial tiles based on theme
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
darkTiles.addTo(this.map);
|
||||
} else {
|
||||
lightTiles.addTo(this.map);
|
||||
}
|
||||
|
||||
// Listen for theme changes
|
||||
this.observeThemeChanges(lightTiles, darkTiles);
|
||||
|
||||
// Add markers cluster group
|
||||
this.map.addLayer(this.markers);
|
||||
|
||||
// Bind map events
|
||||
this.bindEvents();
|
||||
|
||||
// Load initial data
|
||||
this.loadMapData();
|
||||
}
|
||||
|
||||
observeThemeChanges(lightTiles, darkTiles) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
this.map.removeLayer(lightTiles);
|
||||
this.map.addLayer(darkTiles);
|
||||
} else {
|
||||
this.map.removeLayer(darkTiles);
|
||||
this.map.addLayer(lightTiles);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Update map when bounds change
|
||||
this.map.on('moveend zoomend', () => {
|
||||
this.updateMapBounds();
|
||||
});
|
||||
|
||||
// Handle filter form changes
|
||||
document.getElementById('map-filters').addEventListener('htmx:afterRequest', (event) => {
|
||||
if (event.detail.successful) {
|
||||
this.loadMapData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadMapData() {
|
||||
try {
|
||||
document.getElementById('map-loading').style.display = 'flex';
|
||||
|
||||
const formData = new FormData(document.getElementById('map-filters'));
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Add form data to params
|
||||
for (let [key, value] of formData.entries()) {
|
||||
params.append(key, value);
|
||||
}
|
||||
|
||||
// Add map bounds
|
||||
const bounds = this.map.getBounds();
|
||||
params.append('north', bounds.getNorth());
|
||||
params.append('south', bounds.getSouth());
|
||||
params.append('east', bounds.getEast());
|
||||
params.append('west', bounds.getWest());
|
||||
params.append('zoom', this.map.getZoom());
|
||||
|
||||
const response = await fetch(`{{ map_api_urls.locations }}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.updateMarkers(data.data);
|
||||
} else {
|
||||
console.error('Map data error:', data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load map data:', error);
|
||||
} finally {
|
||||
document.getElementById('map-loading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updateMarkers(data) {
|
||||
// Clear existing markers
|
||||
this.markers.clearLayers();
|
||||
|
||||
// Add location markers
|
||||
if (data.locations) {
|
||||
data.locations.forEach(location => {
|
||||
this.addLocationMarker(location);
|
||||
});
|
||||
}
|
||||
|
||||
// Add cluster markers
|
||||
if (data.clusters) {
|
||||
data.clusters.forEach(cluster => {
|
||||
this.addClusterMarker(cluster);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addLocationMarker(location) {
|
||||
const icon = this.getLocationIcon(location.type);
|
||||
const marker = L.marker([location.latitude, location.longitude], { icon });
|
||||
|
||||
// Create popup content
|
||||
const popupContent = this.createPopupContent(location);
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
// Add click handler for detailed view
|
||||
marker.on('click', () => {
|
||||
this.showLocationDetails(location.type, location.id);
|
||||
});
|
||||
|
||||
this.markers.addLayer(marker);
|
||||
}
|
||||
|
||||
addClusterMarker(cluster) {
|
||||
const marker = L.marker([cluster.latitude, cluster.longitude], {
|
||||
icon: L.divIcon({
|
||||
className: 'cluster-marker',
|
||||
html: `<div class="cluster-marker-inner">${cluster.count}</div>`,
|
||||
iconSize: [40, 40]
|
||||
})
|
||||
});
|
||||
|
||||
marker.bindPopup(`${cluster.count} locations in this area`);
|
||||
this.markers.addLayer(marker);
|
||||
}
|
||||
|
||||
getLocationIcon(type) {
|
||||
const iconMap = {
|
||||
'park': '🎢',
|
||||
'ride': '🎠',
|
||||
'company': '🏢',
|
||||
'generic': '📍'
|
||||
};
|
||||
|
||||
return L.divIcon({
|
||||
className: 'location-marker',
|
||||
html: `<div class="location-marker-inner">${iconMap[type] || '📍'}</div>`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15]
|
||||
});
|
||||
}
|
||||
|
||||
createPopupContent(location) {
|
||||
return `
|
||||
<div class="location-info-popup">
|
||||
<h3>${location.name}</h3>
|
||||
${location.formatted_location ? `<p><i class="fas fa-map-marker-alt mr-1"></i>${location.formatted_location}</p>` : ''}
|
||||
${location.operator ? `<p><i class="fas fa-building mr-1"></i>${location.operator}</p>` : ''}
|
||||
${location.ride_count ? `<p><i class="fas fa-rocket mr-1"></i>${location.ride_count} rides</p>` : ''}
|
||||
<div class="mt-2">
|
||||
<button onclick="thrillwikiMap.showLocationDetails('${location.type}', ${location.id})"
|
||||
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
|
||||
View Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
showLocationDetails(type, id) {
|
||||
htmx.ajax('GET', `{% url 'maps:htmx_location_detail' 'TYPE' 0 %}`.replace('TYPE', type).replace('0', id), {
|
||||
target: '#location-modal',
|
||||
swap: 'innerHTML'
|
||||
}).then(() => {
|
||||
document.getElementById('location-modal').classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
updateMapBounds() {
|
||||
// This could trigger an HTMX request to update data based on new bounds
|
||||
// For now, we'll just reload data when the map moves significantly
|
||||
clearTimeout(this.boundsUpdateTimeout);
|
||||
this.boundsUpdateTimeout = setTimeout(() => {
|
||||
this.loadMapData();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize map when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.thrillwikiMap = new ThrillWikiMap('map-container', {
|
||||
{% if initial_bounds %}
|
||||
center: [{{ initial_bounds.north|add:initial_bounds.south|floatformat:6|div:2 }}, {{ initial_bounds.east|add:initial_bounds.west|floatformat:6|div:2 }}],
|
||||
{% endif %}
|
||||
enableClustering: {{ enable_clustering|yesno:"true,false" }}
|
||||
});
|
||||
|
||||
// Handle filter pill toggles
|
||||
document.querySelectorAll('.filter-pill').forEach(pill => {
|
||||
const checkbox = pill.querySelector('input[type="checkbox"]');
|
||||
|
||||
// Set initial state
|
||||
if (checkbox.checked) {
|
||||
pill.classList.add('active');
|
||||
}
|
||||
|
||||
pill.addEventListener('click', () => {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
pill.classList.toggle('active', checkbox.checked);
|
||||
|
||||
// Trigger form change
|
||||
document.getElementById('map-filters').dispatchEvent(new Event('change'));
|
||||
});
|
||||
});
|
||||
|
||||
// Close modal handler
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'location-modal') {
|
||||
document.getElementById('location-modal').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.cluster-marker {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cluster-marker-inner {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.location-marker {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.location-marker-inner {
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
line-height: 30px;
|
||||
filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.3));
|
||||
}
|
||||
|
||||
.dark .cluster-marker-inner {
|
||||
border-color: #374151;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
142
backend/templates/media/partials/photo_display.html
Normal file
142
backend/templates/media/partials/photo_display.html
Normal file
@@ -0,0 +1,142 @@
|
||||
{% load static %}
|
||||
|
||||
<div x-data="photoDisplay({
|
||||
photos: [
|
||||
{% for photo in photos %}
|
||||
{
|
||||
id: {{ photo.id }},
|
||||
url: '{{ photo.image.url }}',
|
||||
caption: '{{ photo.caption|default:""|escapejs }}',
|
||||
date_taken: '{{ photo.date_taken|date:"F j, Y g:i A"|default:""|escapejs }}',
|
||||
uploaded_by: '{{ photo.uploaded_by.username|default:""|escapejs }}'
|
||||
}{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
contentType: '{{ content_type }}',
|
||||
objectId: {{ object_id }},
|
||||
csrfToken: '{{ csrf_token }}',
|
||||
uploadUrl: '{% url "photos:upload" %}'
|
||||
})" class="w-full">
|
||||
<!-- Photo Grid - Adaptive Layout -->
|
||||
<div class="relative">
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="showSuccess"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
||||
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
||||
class="absolute top-0 left-0 right-0 z-20 px-4 py-2 mx-auto mt-2 text-sm text-center text-green-800 transform -translate-y-full bg-green-100 rounded-lg w-fit dark:bg-green-200 dark:text-green-900">
|
||||
Photo uploaded successfully!
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<template x-if="uploading">
|
||||
<div class="absolute top-0 left-0 right-0 z-20 p-4 mx-auto mt-2 bg-white rounded-lg shadow-lg w-fit dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Uploading...</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="uploadProgress + '%'"></span>
|
||||
</div>
|
||||
<div class="w-64 h-2 bg-gray-200 rounded-full dark:bg-gray-700">
|
||||
<div class="h-2 transition-all duration-300 bg-blue-600 rounded-full"
|
||||
:style="'width: ' + uploadProgress + '%'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error Message -->
|
||||
<template x-if="error">
|
||||
<div class="absolute top-0 left-0 right-0 z-20 px-4 py-2 mx-auto mt-2 text-sm text-center text-red-800 bg-red-100 rounded-lg w-fit dark:bg-red-200 dark:text-red-900"
|
||||
x-text="error"></div>
|
||||
</template>
|
||||
|
||||
<!-- Photo Grid -->
|
||||
<div :class="{
|
||||
'grid gap-4': true,
|
||||
'grid-cols-1 max-w-2xl mx-auto': photos.length === 1,
|
||||
'grid-cols-2 max-w-3xl mx-auto': photos.length === 2,
|
||||
'grid-cols-2 md:grid-cols-3 lg:grid-cols-4': photos.length > 2
|
||||
}">
|
||||
<template x-for="photo in photos" :key="photo.id">
|
||||
<div class="relative cursor-pointer group aspect-w-16 aspect-h-9" @click="showFullscreen(photo)">
|
||||
<img :src="photo.url"
|
||||
:alt="photo.caption || ''"
|
||||
class="object-cover transition-transform duration-300 rounded-lg group-hover:scale-105">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- No Photos Message -->
|
||||
<template x-if="photos.length === 0">
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<i class="mb-4 text-4xl text-gray-400 fas fa-camera dark:text-gray-600"></i>
|
||||
<p class="mb-4 text-gray-500 dark:text-gray-400">No photos available yet.</p>
|
||||
{% if user.is_authenticated and perms.media.add_photo %}
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">Click the upload button to add the first photo!</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen Photo Modal -->
|
||||
<div x-show="fullscreenPhoto"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
|
||||
@click.self="fullscreenPhoto = null"
|
||||
@keydown.escape.window="fullscreenPhoto = null">
|
||||
<div class="relative p-4 mx-auto max-w-7xl">
|
||||
<!-- Close Button -->
|
||||
<button @click="fullscreenPhoto = null"
|
||||
class="absolute text-white top-4 right-4 hover:text-gray-300">
|
||||
<i class="text-2xl fas fa-times"></i>
|
||||
</button>
|
||||
|
||||
<!-- Photo Container -->
|
||||
<div class="relative">
|
||||
<img :src="fullscreenPhoto?.url"
|
||||
:alt="fullscreenPhoto?.caption || ''"
|
||||
class="max-h-[90vh] w-auto mx-auto rounded-lg">
|
||||
|
||||
<!-- Photo Info Overlay -->
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 text-white bg-black/50 rounded-b-lg">
|
||||
<!-- Caption -->
|
||||
<div x-show="fullscreenPhoto?.caption"
|
||||
class="mb-2 text-lg font-medium"
|
||||
x-text="fullscreenPhoto?.caption">
|
||||
</div>
|
||||
|
||||
<!-- Photo Details -->
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<!-- Uploaded By -->
|
||||
<div x-show="fullscreenPhoto?.uploaded_by" class="flex items-center">
|
||||
<i class="mr-2 fas fa-user"></i>
|
||||
<span x-text="fullscreenPhoto?.uploaded_by"></span>
|
||||
</div>
|
||||
|
||||
<!-- Date Taken -->
|
||||
<div x-show="fullscreenPhoto?.date_taken" class="flex items-center">
|
||||
<i class="mr-2 fas fa-calendar"></i>
|
||||
<span x-text="fullscreenPhoto?.date_taken"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="absolute flex gap-2 bottom-4 right-4">
|
||||
<a :href="fullscreenPhoto?.url"
|
||||
download
|
||||
class="p-2 text-white rounded-full bg-white/10 hover:bg-white/20"
|
||||
title="Download">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
<button @click="sharePhoto(fullscreenPhoto)"
|
||||
class="p-2 text-white rounded-full bg-white/10 hover:bg-white/20"
|
||||
title="Share">
|
||||
<i class="fas fa-share-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
257
backend/templates/media/partials/photo_manager.html
Normal file
257
backend/templates/media/partials/photo_manager.html
Normal file
@@ -0,0 +1,257 @@
|
||||
{% load static %}
|
||||
|
||||
<div x-data="photoManager({
|
||||
photos: [
|
||||
{% for photo in photos %}
|
||||
{
|
||||
id: {{ photo.id }},
|
||||
url: '{{ photo.image.url }}',
|
||||
caption: '{{ photo.caption|default:""|escapejs }}',
|
||||
is_primary: {{ photo.is_primary|yesno:"true,false" }}
|
||||
}{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
contentType: '{{ content_type }}',
|
||||
objectId: {{ object_id }},
|
||||
csrfToken: '{{ csrf_token }}',
|
||||
uploadUrl: '{% url "photos:upload" %}'
|
||||
})" class="w-full">
|
||||
<div class="relative space-y-6">
|
||||
<!-- Upload Section -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Photos</h3>
|
||||
<label class="cursor-pointer btn-secondary">
|
||||
<i class="mr-2 fas fa-camera"></i>
|
||||
<span>Upload Photo</span>
|
||||
<input type="file"
|
||||
class="hidden"
|
||||
accept="image/*"
|
||||
@change="handleFileSelect"
|
||||
multiple>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="showSuccess"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
||||
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
||||
class="p-4 text-sm text-green-800 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-900">
|
||||
Photo uploaded successfully!
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<template x-if="uploading">
|
||||
<div class="p-4 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Uploading...</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="uploadProgress + '%'"></span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-gray-200 rounded-full dark:bg-gray-700">
|
||||
<div class="h-2 transition-all duration-300 bg-blue-600 rounded-full"
|
||||
:style="'width: ' + uploadProgress + '%'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error Message -->
|
||||
<template x-if="error">
|
||||
<div class="p-4 text-sm text-red-800 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-900"
|
||||
x-text="error"></div>
|
||||
</template>
|
||||
|
||||
<!-- Photo Grid -->
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<template x-for="photo in photos" :key="photo.id">
|
||||
<div class="relative p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||
<!-- Photo -->
|
||||
<div class="relative aspect-w-16 aspect-h-9 group">
|
||||
<img :src="photo.url"
|
||||
:alt="photo.caption || ''"
|
||||
class="object-cover rounded-lg">
|
||||
</div>
|
||||
|
||||
<!-- Caption -->
|
||||
<div class="mt-4 space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Caption</label>
|
||||
<textarea x-model="photo.caption"
|
||||
@change="updateCaption(photo)"
|
||||
class="w-full text-sm border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<button @click="togglePrimary(photo)"
|
||||
:class="{
|
||||
'text-yellow-600 dark:text-yellow-400': photo.is_primary,
|
||||
'text-gray-400 dark:text-gray-500': !photo.is_primary
|
||||
}"
|
||||
class="flex items-center gap-2 px-3 py-1 text-sm font-medium rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<i class="fas" :class="photo.is_primary ? 'fa-star' : 'fa-star-o'"></i>
|
||||
<span x-text="photo.is_primary ? 'Featured' : 'Set as Featured'"></span>
|
||||
</button>
|
||||
|
||||
<button @click="deletePhoto(photo)"
|
||||
class="flex items-center gap-2 px-3 py-1 text-sm font-medium text-red-600 rounded-lg dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20">
|
||||
<i class="fas fa-trash"></i>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- No Photos Message -->
|
||||
<template x-if="photos.length === 0">
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<i class="mb-4 text-4xl text-gray-400 fas fa-camera dark:text-gray-600"></i>
|
||||
<p class="mb-4 text-gray-500 dark:text-gray-400">No photos available yet.</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">Click the upload button to add photos!</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AlpineJS Component Script -->
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('photoManager', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({
|
||||
photos,
|
||||
uploading: false,
|
||||
uploadProgress: 0,
|
||||
error: null,
|
||||
showSuccess: false,
|
||||
|
||||
async handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
if (!files.length) return;
|
||||
|
||||
this.uploading = true;
|
||||
this.uploadProgress = 0;
|
||||
this.error = null;
|
||||
this.showSuccess = false;
|
||||
|
||||
const totalFiles = files.length;
|
||||
let completedFiles = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('app_label', contentType.split('.')[0]);
|
||||
formData.append('model', contentType.split('.')[1]);
|
||||
formData.append('object_id', objectId);
|
||||
|
||||
try {
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const photo = await response.json();
|
||||
this.photos.push(photo);
|
||||
completedFiles++;
|
||||
this.uploadProgress = (completedFiles / totalFiles) * 100;
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to upload photo. Please try again.';
|
||||
console.error('Upload error:', err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.uploading = false;
|
||||
event.target.value = ''; // Reset file input
|
||||
|
||||
if (!this.error) {
|
||||
this.showSuccess = true;
|
||||
setTimeout(() => {
|
||||
this.showSuccess = false;
|
||||
}, 3000);
|
||||
}
|
||||
},
|
||||
|
||||
async updateCaption(photo) {
|
||||
try {
|
||||
const response = await fetch(`${uploadUrl}${photo.id}/caption/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
caption: photo.caption
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update caption');
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to update caption';
|
||||
console.error('Caption update error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async togglePrimary(photo) {
|
||||
try {
|
||||
const response = await fetch(`${uploadUrl}${photo.id}/primary/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update primary photo');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
this.photos = this.photos.map(p => ({
|
||||
...p,
|
||||
is_primary: p.id === photo.id
|
||||
}));
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to update primary photo';
|
||||
console.error('Primary photo update error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async deletePhoto(photo) {
|
||||
if (!confirm('Are you sure you want to delete this photo?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${uploadUrl}${photo.id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete photo');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
this.photos = this.photos.filter(p => p.id !== photo.id);
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to delete photo';
|
||||
console.error('Delete error:', err);
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
270
backend/templates/media/partials/photo_upload.html
Normal file
270
backend/templates/media/partials/photo_upload.html
Normal file
@@ -0,0 +1,270 @@
|
||||
{% load static %}
|
||||
|
||||
<div x-data="photoUpload({
|
||||
contentType: '{{ content_type }}',
|
||||
objectId: {{ object_id }},
|
||||
csrfToken: '{{ csrf_token }}',
|
||||
uploadUrl: '{% url "photos:upload" %}',
|
||||
maxFiles: {{ max_files|default:5 }},
|
||||
initialPhotos: [
|
||||
{% for photo in photos %}
|
||||
{
|
||||
id: {{ photo.id }},
|
||||
url: '{{ photo.image.url }}',
|
||||
caption: '{{ photo.caption|default:""|escapejs }}',
|
||||
is_primary: {{ photo.is_primary|yesno:"true,false" }}
|
||||
}{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
]
|
||||
})" class="w-full">
|
||||
<!-- Photo Upload Button -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Photos</h3>
|
||||
<template x-if="canAddMorePhotos">
|
||||
<label class="cursor-pointer btn-secondary">
|
||||
<i class="mr-2 fas fa-camera"></i>
|
||||
<span>Upload Photo</span>
|
||||
<input type="file"
|
||||
class="hidden"
|
||||
accept="image/*"
|
||||
@change="handleFileSelect"
|
||||
multiple>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<template x-if="uploading">
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Uploading...</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="uploadProgress + '%'"></span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
|
||||
<div class="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
|
||||
:style="'width: ' + uploadProgress + '%'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<template x-if="error">
|
||||
<div class="p-4 mb-4 text-sm text-red-800 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
|
||||
x-text="error"></div>
|
||||
</template>
|
||||
|
||||
<!-- Photo Grid -->
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4" x-show="photos.length > 0">
|
||||
<template x-for="photo in photos" :key="photo.id">
|
||||
<div class="relative group aspect-w-16 aspect-h-9">
|
||||
<img :src="photo.url"
|
||||
:alt="photo.caption || ''"
|
||||
class="object-cover rounded-lg">
|
||||
|
||||
<!-- Overlay Controls -->
|
||||
<div class="absolute inset-0 flex items-center justify-center transition-opacity rounded-lg opacity-0 bg-black/50 group-hover:opacity-100">
|
||||
<!-- Primary Photo Toggle -->
|
||||
<button @click="togglePrimary(photo)"
|
||||
class="p-2 mx-1 text-white bg-blue-600 rounded-full hover:bg-blue-700"
|
||||
:class="{ 'bg-yellow-500 hover:bg-yellow-600': photo.is_primary }">
|
||||
<i class="fas" :class="photo.is_primary ? 'fa-star' : 'fa-star-o'"></i>
|
||||
</button>
|
||||
|
||||
<!-- Edit Caption -->
|
||||
<button @click="editCaption(photo)"
|
||||
class="p-2 mx-1 text-white bg-blue-600 rounded-full hover:bg-blue-700">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
|
||||
<!-- Delete Photo -->
|
||||
<button @click="deletePhoto(photo)"
|
||||
class="p-2 mx-1 text-white bg-red-600 rounded-full hover:bg-red-700">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- No Photos Message -->
|
||||
<template x-if="photos.length === 0">
|
||||
<div class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No photos available. Click the upload button to add photos.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Caption Edit Modal -->
|
||||
<div x-show="showCaptionModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
@click.self="showCaptionModal = false">
|
||||
<div class="w-full max-w-md p-6 bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Edit Photo Caption</h3>
|
||||
<input type="text"
|
||||
x-model="editingPhoto.caption"
|
||||
class="w-full p-2 mb-4 border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
placeholder="Enter caption">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="showCaptionModal = false"
|
||||
class="btn-secondary">Cancel</button>
|
||||
<button @click="saveCaption"
|
||||
class="btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AlpineJS Component Script -->
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('photoUpload', ({ contentType, objectId, csrfToken, uploadUrl, maxFiles, initialPhotos }) => ({
|
||||
photos: initialPhotos || [],
|
||||
uploading: false,
|
||||
uploadProgress: 0,
|
||||
error: null,
|
||||
showCaptionModal: false,
|
||||
editingPhoto: { caption: '' },
|
||||
|
||||
get canAddMorePhotos() {
|
||||
return this.photos.length < maxFiles;
|
||||
},
|
||||
|
||||
async handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
if (!files.length) return;
|
||||
|
||||
if (this.photos.length + files.length > maxFiles) {
|
||||
this.error = `You can only upload up to ${maxFiles} photos`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploading = true;
|
||||
this.uploadProgress = 0;
|
||||
this.error = null;
|
||||
|
||||
const totalFiles = files.length;
|
||||
let completedFiles = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('app_label', contentType.split('.')[0]);
|
||||
formData.append('model', contentType.split('.')[1]);
|
||||
formData.append('object_id', objectId);
|
||||
|
||||
try {
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const photo = await response.json();
|
||||
this.photos.push(photo);
|
||||
completedFiles++;
|
||||
this.uploadProgress = (completedFiles / totalFiles) * 100;
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to upload photo. Please try again.';
|
||||
console.error('Upload error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
this.uploading = false;
|
||||
event.target.value = ''; // Reset file input
|
||||
},
|
||||
|
||||
async togglePrimary(photo) {
|
||||
try {
|
||||
const response = await fetch(`${uploadUrl}${photo.id}/primary/`, { // Added trailing slash
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update primary photo');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
this.photos = this.photos.map(p => ({
|
||||
...p,
|
||||
is_primary: p.id === photo.id
|
||||
}));
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to update primary photo';
|
||||
console.error('Primary photo update error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
editCaption(photo) {
|
||||
this.editingPhoto = { ...photo };
|
||||
this.showCaptionModal = true;
|
||||
},
|
||||
|
||||
async saveCaption() {
|
||||
try {
|
||||
const response = await fetch(`${uploadUrl}${this.editingPhoto.id}/caption/`, { // Added trailing slash
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
caption: this.editingPhoto.caption
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update caption');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
this.photos = this.photos.map(p =>
|
||||
p.id === this.editingPhoto.id
|
||||
? { ...p, caption: this.editingPhoto.caption }
|
||||
: p
|
||||
);
|
||||
|
||||
this.showCaptionModal = false;
|
||||
this.editingPhoto = { caption: '' };
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to update caption';
|
||||
console.error('Caption update error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async deletePhoto(photo) {
|
||||
if (!confirm('Are you sure you want to delete this photo?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${uploadUrl}${photo.id}/`, { // Added trailing slash
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete photo');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
this.photos = this.photos.filter(p => p.id !== photo.id);
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to delete photo';
|
||||
console.error('Delete error:', err);
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
297
backend/templates/moderation/dashboard.html
Normal file
297
backend/templates/moderation/dashboard.html
Normal file
@@ -0,0 +1,297 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}ThrillWiki Moderation{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Base Styles */
|
||||
:root {
|
||||
--loading-gradient: linear-gradient(90deg, var(--tw-gradient-from) 0%, var(--tw-gradient-to) 50%, var(--tw-gradient-from) 100%);
|
||||
}
|
||||
|
||||
/* Responsive Layout */
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-responsive {
|
||||
@apply grid-cols-1;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
@apply flex-col;
|
||||
}
|
||||
|
||||
.action-buttons > * {
|
||||
@apply w-full justify-center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
.form-select {
|
||||
@apply rounded-lg border-gray-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 bg-gray-800 text-gray-300 transition-colors duration-200;
|
||||
}
|
||||
|
||||
/* State Management */
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
.htmx-request .htmx-indicator {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.htmx-request.htmx-indicator {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.htmx-indicator {
|
||||
@apply opacity-0 transition-opacity duration-200;
|
||||
}
|
||||
|
||||
/* Skeleton Loading Animation */
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
/* Custom Animations */
|
||||
@keyframes shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.animate-shimmer::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transform: translateX(-100%);
|
||||
background-image: var(--loading-gradient);
|
||||
animation: shimmer 2s infinite;
|
||||
content: '';
|
||||
}
|
||||
|
||||
/* Accessibility Enhancements */
|
||||
:focus-visible {
|
||||
@apply outline-hidden ring-2 ring-blue-500 ring-offset-2 ring-offset-white dark:ring-offset-gray-900;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-shimmer::after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch Device Optimizations */
|
||||
@media (hover: none) {
|
||||
.hover\:shadow-md {
|
||||
@apply shadow-xs;
|
||||
}
|
||||
|
||||
.action-buttons > * {
|
||||
@apply active:transform active:scale-95;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Optimizations */
|
||||
.dark .animate-shimmer::after {
|
||||
--tw-gradient-from: rgba(31, 41, 55, 0);
|
||||
--tw-gradient-to: rgba(31, 41, 55, 0.1);
|
||||
}
|
||||
|
||||
/* Error States */
|
||||
.error-shake {
|
||||
animation: shake 0.82s cubic-bezier(.36,.07,.19,.97) both;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
10%, 90% { transform: translate3d(-1px, 0, 0); }
|
||||
20%, 80% { transform: translate3d(2px, 0, 0); }
|
||||
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
|
||||
40%, 60% { transform: translate3d(4px, 0, 0); }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container max-w-6xl px-4 py-6 mx-auto">
|
||||
<div id="dashboard-content" class="relative transition-all duration-200">
|
||||
{% block moderation_content %}
|
||||
{% include "moderation/partials/dashboard_content.html" %}
|
||||
{% endblock %}
|
||||
|
||||
<!-- Loading Skeleton -->
|
||||
<div class="absolute inset-0 htmx-indicator" id="loading-skeleton">
|
||||
{% include "moderation/partials/loading_skeleton.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div class="absolute inset-0 hidden" id="error-state">
|
||||
<div class="flex flex-col items-center justify-center h-full p-6 space-y-4 text-center">
|
||||
<div class="p-4 text-red-500 bg-red-100 rounded-full dark:bg-red-900/40">
|
||||
<i class="text-4xl fas fa-exclamation-circle"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-red-600 dark:text-red-400">
|
||||
Something went wrong
|
||||
</h3>
|
||||
<p class="max-w-md text-gray-600 dark:text-gray-400" id="error-message">
|
||||
There was a problem loading the content. Please try again.
|
||||
</p>
|
||||
<button class="px-4 py-2 font-medium text-white transition-all duration-200 bg-red-600 rounded-lg hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600"
|
||||
onclick="window.location.reload()">
|
||||
<i class="mr-2 fas fa-sync-alt"></i>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// HTMX Configuration and Enhancements
|
||||
document.body.addEventListener('htmx:configRequest', function(evt) {
|
||||
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
|
||||
});
|
||||
|
||||
// Loading and Error State Management
|
||||
const dashboard = {
|
||||
content: document.getElementById('dashboard-content'),
|
||||
skeleton: document.getElementById('loading-skeleton'),
|
||||
errorState: document.getElementById('error-state'),
|
||||
errorMessage: document.getElementById('error-message'),
|
||||
|
||||
showLoading() {
|
||||
this.content.setAttribute('aria-busy', 'true');
|
||||
this.content.style.opacity = '0';
|
||||
this.errorState.classList.add('hidden');
|
||||
},
|
||||
|
||||
hideLoading() {
|
||||
this.content.setAttribute('aria-busy', 'false');
|
||||
this.content.style.opacity = '1';
|
||||
},
|
||||
|
||||
showError(message) {
|
||||
this.errorState.classList.remove('hidden');
|
||||
this.errorMessage.textContent = message || 'There was a problem loading the content. Please try again.';
|
||||
// Announce error to screen readers
|
||||
this.errorMessage.setAttribute('role', 'alert');
|
||||
}
|
||||
};
|
||||
|
||||
// Enhanced HTMX Event Handlers
|
||||
document.body.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
if (evt.detail.target.id === 'dashboard-content') {
|
||||
dashboard.showLoading();
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
|
||||
if (evt.detail.target.id === 'dashboard-content') {
|
||||
dashboard.hideLoading();
|
||||
// Reset focus for accessibility
|
||||
const firstFocusable = evt.detail.target.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (firstFocusable) {
|
||||
firstFocusable.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:responseError', function(evt) {
|
||||
if (evt.detail.target.id === 'dashboard-content') {
|
||||
dashboard.showError(evt.detail.error);
|
||||
}
|
||||
});
|
||||
|
||||
// Search Input Debouncing
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Apply debouncing to search inputs
|
||||
document.querySelectorAll('[data-search]').forEach(input => {
|
||||
const originalSearch = () => {
|
||||
htmx.trigger(input, 'input');
|
||||
};
|
||||
const debouncedSearch = debounce(originalSearch, 300);
|
||||
|
||||
input.addEventListener('input', (e) => {
|
||||
e.preventDefault();
|
||||
debouncedSearch();
|
||||
});
|
||||
});
|
||||
|
||||
// Virtual Scrolling for Large Lists
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '100px',
|
||||
threshold: 0.1
|
||||
};
|
||||
|
||||
const loadMoreContent = (entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !entry.target.classList.contains('loading')) {
|
||||
entry.target.classList.add('loading');
|
||||
htmx.trigger(entry.target, 'intersect');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(loadMoreContent, observerOptions);
|
||||
document.querySelectorAll('[data-infinite-scroll]').forEach(el => observer.observe(el));
|
||||
|
||||
// Keyboard Navigation Enhancement
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const openModals = document.querySelectorAll('[x-show="showNotes"]');
|
||||
openModals.forEach(modal => {
|
||||
const alpineData = modal.__x.$data;
|
||||
if (alpineData.showNotes) {
|
||||
alpineData.showNotes = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
7
backend/templates/moderation/edit_submission_list.html
Normal file
7
backend/templates/moderation/edit_submission_list.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% extends "moderation/dashboard.html" %}
|
||||
|
||||
{% block moderation_content %}
|
||||
<div id="submissions-content">
|
||||
{% include "moderation/partials/edit_submission_content.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
127
backend/templates/moderation/edit_submissions.html
Normal file
127
backend/templates/moderation/edit_submissions.html
Normal file
@@ -0,0 +1,127 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Moderation - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h1 class="mb-6 text-3xl font-bold text-gray-900 dark:text-white">Moderation Queue</h1>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<ul class="flex flex-wrap -mb-px" role="tablist">
|
||||
<li class="mr-2">
|
||||
<button class="tab-button {% if active_tab == 'new' %}active{% endif %}"
|
||||
data-tab="new"
|
||||
hx-get="{% url 'moderation:edit_submissions' %}?tab=new"
|
||||
hx-target="#submissions-content"
|
||||
hx-push-url="true">
|
||||
New
|
||||
{% if new_count %}<span class="ml-2 badge">{{ new_count }}</span>{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="mr-2">
|
||||
<button class="tab-button {% if active_tab == 'approved' %}active{% endif %}"
|
||||
data-tab="approved"
|
||||
hx-get="{% url 'moderation:edit_submissions' %}?tab=approved"
|
||||
hx-target="#submissions-content"
|
||||
hx-push-url="true">
|
||||
Approved
|
||||
</button>
|
||||
</li>
|
||||
<li class="mr-2">
|
||||
<button class="tab-button {% if active_tab == 'rejected' %}active{% endif %}"
|
||||
data-tab="rejected"
|
||||
hx-get="{% url 'moderation:edit_submissions' %}?tab=rejected"
|
||||
hx-target="#submissions-content"
|
||||
hx-push-url="true">
|
||||
Rejected
|
||||
</button>
|
||||
</li>
|
||||
{% if user.role == 'ADMIN' or user.role == 'SUPERUSER' %}
|
||||
<li>
|
||||
<button class="tab-button {% if active_tab == 'escalated' %}active{% endif %}"
|
||||
data-tab="escalated"
|
||||
hx-get="{% url 'moderation:edit_submissions' %}?tab=escalated"
|
||||
hx-target="#submissions-content"
|
||||
hx-push-url="true">
|
||||
Escalated
|
||||
{% if escalated_count %}<span class="ml-2 badge">{{ escalated_count }}</span>{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div id="submissions-content">
|
||||
{% include 'moderation/partials/submission_list.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.tab-button {
|
||||
@apply inline-flex items-center px-4 py-2 text-sm font-medium border-b-2 border-transparent rounded-t-lg hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
@apply text-blue-600 border-blue-600 dark:text-blue-500 dark:border-blue-500;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply px-2 py-1 text-xs font-semibold text-white bg-blue-500 rounded-full;
|
||||
}
|
||||
|
||||
.submission-card {
|
||||
@apply p-4 mb-4 bg-white border rounded-lg shadow dark:bg-gray-700 dark:border-gray-600;
|
||||
}
|
||||
|
||||
.submission-header {
|
||||
@apply flex items-center justify-between mb-2;
|
||||
}
|
||||
|
||||
.submission-title {
|
||||
@apply text-lg font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.submission-meta {
|
||||
@apply text-sm text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.submission-changes {
|
||||
@apply mt-4 space-y-2;
|
||||
}
|
||||
|
||||
.change-item {
|
||||
@apply flex items-start;
|
||||
}
|
||||
|
||||
.change-label {
|
||||
@apply w-32 font-medium text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.change-value {
|
||||
@apply flex-1 text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
@apply flex gap-2 mt-4;
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
@apply px-4 py-2 text-white bg-green-500 rounded-lg hover:bg-green-600;
|
||||
}
|
||||
|
||||
.btn-reject {
|
||||
@apply px-4 py-2 text-white bg-red-500 rounded-lg hover:bg-red-600;
|
||||
}
|
||||
|
||||
.btn-escalate {
|
||||
@apply px-4 py-2 text-white bg-yellow-500 rounded-lg hover:bg-yellow-600;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
135
backend/templates/moderation/partials/coaster_fields.html
Normal file
135
backend/templates/moderation/partials/coaster_fields.html
Normal file
@@ -0,0 +1,135 @@
|
||||
{% load moderation_tags %}
|
||||
|
||||
<div class="p-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<h3 class="mb-4 text-lg font-semibold">Coaster Stats</h3>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Height (ft):
|
||||
</label>
|
||||
<input type="number"
|
||||
name="stats.height_ft"
|
||||
value="{{ stats.height_ft }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Height in feet">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Length (ft):
|
||||
</label>
|
||||
<input type="number"
|
||||
name="stats.length_ft"
|
||||
value="{{ stats.length_ft }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Length in feet">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Speed (mph):
|
||||
</label>
|
||||
<input type="number"
|
||||
name="stats.speed_mph"
|
||||
value="{{ stats.speed_mph }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Speed in mph">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Inversions:
|
||||
</label>
|
||||
<input type="number"
|
||||
name="stats.inversions"
|
||||
value="{{ stats.inversions }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Number of inversions">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Launch Type:
|
||||
</label>
|
||||
<select name="stats.launch_type"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Select launch type</option>
|
||||
<option value="CHAIN_LIFT" {% if stats.launch_type == 'CHAIN_LIFT' %}selected{% endif %}>Chain Lift</option>
|
||||
<option value="LSM" {% if stats.launch_type == 'LSM' %}selected{% endif %}>LSM</option>
|
||||
<option value="HYDRAULIC" {% if stats.launch_type == 'HYDRAULIC' %}selected{% endif %}>Hydraulic</option>
|
||||
<option value="TIRE_DRIVE" {% if stats.launch_type == 'TIRE_DRIVE' %}selected{% endif %}>Tire Drive</option>
|
||||
<option value="CABLE_LIFT" {% if stats.launch_type == 'CABLE_LIFT' %}selected{% endif %}>Cable Lift</option>
|
||||
<option value="OTHER" {% if stats.launch_type == 'OTHER' %}selected{% endif %}>Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Track Material:
|
||||
</label>
|
||||
<select name="stats.track_material"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Select track material</option>
|
||||
<option value="STEEL" {% if stats.track_material == 'STEEL' %}selected{% endif %}>Steel</option>
|
||||
<option value="WOOD" {% if stats.track_material == 'WOOD' %}selected{% endif %}>Wood</option>
|
||||
<option value="HYBRID" {% if stats.track_material == 'HYBRID' %}selected{% endif %}>Hybrid</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Coaster Type:
|
||||
</label>
|
||||
<select name="stats.roller_coaster_type"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Select coaster type</option>
|
||||
<option value="SIT_DOWN" {% if stats.roller_coaster_type == 'SIT_DOWN' %}selected{% endif %}>Sit Down</option>
|
||||
<option value="INVERTED" {% if stats.roller_coaster_type == 'INVERTED' %}selected{% endif %}>Inverted</option>
|
||||
<option value="FLYING" {% if stats.roller_coaster_type == 'FLYING' %}selected{% endif %}>Flying</option>
|
||||
<option value="STAND_UP" {% if stats.roller_coaster_type == 'STAND_UP' %}selected{% endif %}>Stand Up</option>
|
||||
<option value="WING" {% if stats.roller_coaster_type == 'WING' %}selected{% endif %}>Wing</option>
|
||||
<option value="OTHER" {% if stats.roller_coaster_type == 'OTHER' %}selected{% endif %}>Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Number of Trains:
|
||||
</label>
|
||||
<input type="number"
|
||||
name="stats.trains_count"
|
||||
value="{{ stats.trains_count }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Number of trains">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Cars per Train:
|
||||
</label>
|
||||
<input type="number"
|
||||
name="stats.cars_per_train"
|
||||
value="{{ stats.cars_per_train }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Cars per train">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Seats per Car:
|
||||
</label>
|
||||
<input type="number"
|
||||
name="stats.seats_per_car"
|
||||
value="{{ stats.seats_per_car }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Seats per car">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
222
backend/templates/moderation/partials/dashboard_content.html
Normal file
222
backend/templates/moderation/partials/dashboard_content.html
Normal file
@@ -0,0 +1,222 @@
|
||||
{% load static %}
|
||||
|
||||
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-gray-200">Moderation Dashboard</h1>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between p-4 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="{% url 'moderation:submission_list' %}?status=PENDING"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'PENDING' or not request.GET.status %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=PENDING"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-clock"></i>
|
||||
<span>Pending</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=APPROVED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'APPROVED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=APPROVED"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-check"></i>
|
||||
<span>Approved</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=REJECTED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'REJECTED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=REJECTED"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-times"></i>
|
||||
<span>Rejected</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=ESCALATED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'ESCALATED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=ESCALATED"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-exclamation-triangle"></i>
|
||||
<span>Escalated</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 text-gray-600 dark:text-gray-400 hover:text-blue-900 hover:bg-blue-100 dark:hover:text-blue-400 dark:hover:bg-blue-900/40"
|
||||
hx-get="{{ request.get_full_path }}"
|
||||
hx-target="#dashboard-content"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-sync-alt"></i>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<form class="mb-6"
|
||||
x-data="{ showFilters: false }"
|
||||
hx-get="{% url 'moderation:submission_list' %}"
|
||||
hx-target="#dashboard-content"
|
||||
hx-trigger="change from:select"
|
||||
hx-push-url="true"
|
||||
aria-label="Submission filters">
|
||||
|
||||
<!-- Mobile Filter Toggle -->
|
||||
<button type="button"
|
||||
class="flex items-center w-full gap-2 p-3 mb-4 font-medium text-left text-gray-700 transition-colors duration-200 bg-gray-100 rounded-lg md:hidden dark:text-gray-300 dark:bg-gray-900"
|
||||
@click="showFilters = !showFilters"
|
||||
:aria-expanded="showFilters"
|
||||
aria-controls="filter-controls">
|
||||
<i class="fas" :class="showFilters ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
|
||||
<span>Filter Options</span>
|
||||
<span class="flex items-center ml-auto space-x-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span class="active-filters">{{ request.GET|length }} active</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<div id="filter-controls"
|
||||
class="grid gap-4 transition-all duration-200 md:grid-cols-3"
|
||||
:class="{'hidden md:grid': !showFilters, 'grid': showFilters}"
|
||||
role="group"
|
||||
aria-label="Filter controls">
|
||||
|
||||
<div class="relative">
|
||||
<label id="submission-type-label"
|
||||
class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Submission Type
|
||||
</label>
|
||||
<select name="submission_type"
|
||||
aria-labelledby="submission-type-label"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50 focus:outline-hidden focus:ring-2 focus:ring-blue-500"
|
||||
@change="$dispatch('filter-changed')">
|
||||
<option value="">All Submissions</option>
|
||||
<option value="text" {% if request.GET.submission_type == 'text' %}selected{% endif %}>Text Submissions</option>
|
||||
<option value="photo" {% if request.GET.submission_type == 'photo' %}selected{% endif %}>Photo Submissions</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<label id="type-label"
|
||||
class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Type
|
||||
</label>
|
||||
<select name="type"
|
||||
aria-labelledby="type-label"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50 focus:outline-hidden focus:ring-2 focus:ring-blue-500"
|
||||
@change="$dispatch('filter-changed')">
|
||||
<option value="">All Types</option>
|
||||
<option value="CREATE" {% if request.GET.type == 'CREATE' %}selected{% endif %}>New Submissions</option>
|
||||
<option value="EDIT" {% if request.GET.type == 'EDIT' %}selected{% endif %}>Edit Submissions</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<label id="content-type-label"
|
||||
class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Content Type
|
||||
</label>
|
||||
<select name="content_type"
|
||||
aria-labelledby="content-type-label"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50 focus:outline-hidden focus:ring-2 focus:ring-blue-500"
|
||||
@change="$dispatch('filter-changed')">
|
||||
<option value="">All Content</option>
|
||||
<option value="park" {% if request.GET.content_type == 'park' %}selected{% endif %}>Parks</option>
|
||||
<option value="ride" {% if request.GET.content_type == 'ride' %}selected{% endif %}>Rides</option>
|
||||
<option value="company" {% if request.GET.content_type == 'company' %}selected{% endif %}>Companies</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Applied Filters Summary -->
|
||||
<div class="hidden pt-2 md:col-span-3 md:block">
|
||||
<div class="flex flex-wrap gap-2"
|
||||
x-show="$store.filters.hasActiveFilters"
|
||||
x-cloak>
|
||||
<template x-for="filter in $store.filters.active" :key="filter.name">
|
||||
<span class="inline-flex items-center px-2 py-1 text-sm bg-blue-100 rounded dark:bg-blue-900/40">
|
||||
<span class="mr-1 text-blue-700 dark:text-blue-300" x-text="filter.label + ':'"></span>
|
||||
<span class="font-medium text-blue-900 dark:text-blue-100" x-text="filter.value"></span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="space-y-4">
|
||||
{% include "moderation/partials/submission_list.html" with submissions=submissions user=user %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"
|
||||
x-data="{
|
||||
show: false,
|
||||
message: '',
|
||||
icon: 'check',
|
||||
color: 'green',
|
||||
showToast(msg, icn = 'check', clr = 'green') {
|
||||
this.message = msg;
|
||||
this.icon = icn;
|
||||
this.color = clr;
|
||||
this.show = true;
|
||||
setTimeout(() => this.show = false, 3000);
|
||||
}
|
||||
}"
|
||||
@submission-approved.window="showToast('Submission approved successfully', 'check', 'green')"
|
||||
@submission-rejected.window="showToast('Submission rejected', 'times', 'red')"
|
||||
@submission-escalated.window="showToast('Submission escalated', 'exclamation-triangle', 'yellow')"
|
||||
@submission-updated.window="showToast('Changes saved successfully', 'check', 'blue')"
|
||||
class="fixed z-50 bottom-4 right-4">
|
||||
|
||||
<div x-show="show"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-full"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 translate-y-full"
|
||||
class="flex items-center w-full max-w-xs p-4 text-gray-400 bg-gray-800 rounded-lg shadow">
|
||||
<div class="inline-flex items-center justify-center shrink-0 w-8 h-8 rounded-lg"
|
||||
:class="{
|
||||
'text-green-400 bg-green-900/40': color === 'green',
|
||||
'text-red-400 bg-red-900/40': color === 'red',
|
||||
'text-yellow-400 bg-yellow-900/40': color === 'yellow',
|
||||
'text-blue-400 bg-blue-900/40': color === 'blue'
|
||||
}">
|
||||
<i class="fas" :class="'fa-' + icon"></i>
|
||||
</div>
|
||||
<div class="ml-3 text-sm font-normal" x-text="message"></div>
|
||||
<button type="button"
|
||||
class="ml-auto -mx-1.5 -my-1.5 text-gray-400 hover:text-gray-300 rounded-lg p-1.5 inline-flex h-8 w-8"
|
||||
@click="show = false">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail.successful) {
|
||||
const path = evt.detail.requestConfig.path;
|
||||
let event;
|
||||
|
||||
if (path.includes('approve')) {
|
||||
event = new CustomEvent('submission-approved');
|
||||
} else if (path.includes('reject')) {
|
||||
event = new CustomEvent('submission-rejected');
|
||||
} else if (path.includes('escalate')) {
|
||||
event = new CustomEvent('submission-escalated');
|
||||
} else if (path.includes('edit')) {
|
||||
event = new CustomEvent('submission-updated');
|
||||
}
|
||||
|
||||
if (event) {
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<div class="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="selectDesignerForSubmission('{{ designer.id }}', '{{ designer.name|escapejs }}', '{{ submission_id }}')">
|
||||
{{ designer.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
|
||||
{% if search_term %}
|
||||
No designers found
|
||||
{% else %}
|
||||
Start typing to search...
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectDesignerForSubmission(id, name, submissionId) {
|
||||
// Debug logging
|
||||
console.log('Selecting designer:', {id, name, submissionId});
|
||||
|
||||
// Find elements
|
||||
const designerInput = document.querySelector(`#designer-input-${submissionId}`);
|
||||
const searchInput = document.querySelector(`#designer-search-${submissionId}`);
|
||||
const resultsDiv = document.querySelector(`#designer-search-results-${submissionId}`);
|
||||
|
||||
// Debug logging
|
||||
console.log('Found elements:', {
|
||||
designerInput: designerInput?.id,
|
||||
searchInput: searchInput?.id,
|
||||
resultsDiv: resultsDiv?.id
|
||||
});
|
||||
|
||||
// Update hidden input
|
||||
if (designerInput) {
|
||||
designerInput.value = id;
|
||||
console.log('Updated designer input value:', designerInput.value);
|
||||
}
|
||||
|
||||
// Update search input
|
||||
if (searchInput) {
|
||||
searchInput.value = name;
|
||||
console.log('Updated search input value:', searchInput.value);
|
||||
}
|
||||
|
||||
// Clear results
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = '';
|
||||
console.log('Cleared results div');
|
||||
}
|
||||
}
|
||||
|
||||
// Close search results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const searchResults = document.querySelectorAll('[id^="designer-search-results-"]');
|
||||
searchResults.forEach(function(resultsDiv) {
|
||||
const searchInput = document.querySelector(`#designer-search-${resultsDiv.id.split('-').pop()}`);
|
||||
if (!resultsDiv.contains(e.target) && e.target !== searchInput) {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,2 @@
|
||||
{% include "moderation/partials/filters.html" %}
|
||||
{% include "moderation/partials/submission_list.html" %}
|
||||
132
backend/templates/moderation/partials/edit_submission_form.html
Normal file
132
backend/templates/moderation/partials/edit_submission_form.html
Normal file
@@ -0,0 +1,132 @@
|
||||
{% load moderation_tags %}
|
||||
|
||||
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50"
|
||||
id="edit-form-{{ submission.id }}">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-300">
|
||||
Edit Submission
|
||||
</h3>
|
||||
|
||||
<form hx-post="{% url 'moderation:edit_submission' submission.id %}"
|
||||
hx-target="#submissions-content"
|
||||
class="space-y-4">
|
||||
|
||||
{% for field, value in changes.items %}
|
||||
{% if field != 'model_name' %}
|
||||
<div class="p-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
{% if field == 'stats' %}
|
||||
Coaster Stats:
|
||||
{% elif field == 'park_area' %}
|
||||
Park Area:
|
||||
{% elif field == 'ride_model' %}
|
||||
Ride Model:
|
||||
{% elif field == 'min_height_in' %}
|
||||
Minimum Height:
|
||||
{% elif field == 'max_height_in' %}
|
||||
Maximum Height:
|
||||
{% elif field == 'capacity_per_hour' %}
|
||||
Hourly Capacity:
|
||||
{% elif field == 'ride_duration_seconds' %}
|
||||
Ride Duration:
|
||||
{% elif field == 'opening_date' %}
|
||||
Opening Date:
|
||||
{% elif field == 'closing_date' %}
|
||||
Closing Date:
|
||||
{% elif field == 'status_since' %}
|
||||
Status Since:
|
||||
{% elif field == 'operating_season' %}
|
||||
Operating Season:
|
||||
{% elif field == 'size_acres' %}
|
||||
Size (Acres):
|
||||
{% elif field == 'post_closing_status' %}
|
||||
Post-Closing Status:
|
||||
{% else %}
|
||||
{{ field|title }}:
|
||||
{% endif %}
|
||||
</label>
|
||||
|
||||
{% if field == 'stats' %}
|
||||
<div class="space-y-2">
|
||||
{% for stat_name, stat_value in value.items %}
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-700 dark:text-gray-400">{{ stat_name|title }}:</label>
|
||||
<input type="text"
|
||||
name="stats.{{ stat_name }}"
|
||||
value="{{ stat_value }}"
|
||||
class="flex-1 px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif field == 'park' %}
|
||||
<select name="{{ field }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
{% for park_id, park_name in parks %}
|
||||
<option value="{{ park_id }}" {% if park_id == value %}selected{% endif %}>{{ park_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% elif field == 'designer' %}
|
||||
<select name="{{ field }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<option value="">None</option>
|
||||
{% for designer_id, designer_name in designers %}
|
||||
<option value="{{ designer_id }}" {% if designer_id == value %}selected{% endif %}>{{ designer_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% elif field == 'manufacturer' %}
|
||||
<select name="{{ field }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<option value="">None</option>
|
||||
{% for manufacturer_id, manufacturer_name in manufacturers %}
|
||||
<option value="{{ manufacturer_id }}" {% if manufacturer_id == value %}selected{% endif %}>{{ manufacturer_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% elif field == 'ride_model' %}
|
||||
<select name="{{ field }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<option value="">None</option>
|
||||
{% for model_id, model_name in ride_models %}
|
||||
<option value="{{ model_id }}" {% if model_id == value %}selected{% endif %}>{{ model_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% elif field == 'park_area' %}
|
||||
<select name="{{ field }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<option value="">None</option>
|
||||
{% for area_id, area_name in park_areas %}
|
||||
<option value="{{ area_id }}" {% if area_id == value %}selected{% endif %}>{{ area_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="{% if field == 'opening_date' or field == 'closing_date' or field == 'status_since' %}date{% else %}text{% endif %}"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="p-4 border rounded-lg bg-blue-50 dark:bg-blue-900/30 border-blue-200/50 dark:border-blue-700/50">
|
||||
<label class="block mb-2 text-sm font-medium text-blue-900 dark:text-blue-300">
|
||||
Notes (required):
|
||||
</label>
|
||||
<textarea name="notes"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg resize-none dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50"
|
||||
rows="3"
|
||||
required
|
||||
placeholder="Explain why you're editing this submission"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button"
|
||||
class="px-4 py-2 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
@click="showEditForm = false">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 font-medium text-white transition-all duration-200 bg-blue-600 rounded-lg hover:bg-blue-500 dark:bg-blue-700 dark:hover:bg-blue-600">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
44
backend/templates/moderation/partials/filters.html
Normal file
44
backend/templates/moderation/partials/filters.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<div class="mb-6 filters">
|
||||
<form class="flex flex-wrap items-end gap-4"
|
||||
hx-get="{% url 'moderation:submission_list' %}"
|
||||
hx-target="#submissions-content"
|
||||
hx-trigger="change"
|
||||
hx-push-url="true">
|
||||
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status
|
||||
</label>
|
||||
<select name="status" class="w-full form-select">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="PENDING" {% if request.GET.status == 'PENDING' %}selected{% endif %}>Pending</option>
|
||||
<option value="APPROVED" {% if request.GET.status == 'APPROVED' %}selected{% endif %}>Approved</option>
|
||||
<option value="REJECTED" {% if request.GET.status == 'REJECTED' %}selected{% endif %}>Rejected</option>
|
||||
<option value="ESCALATED" {% if request.GET.status == 'ESCALATED' %}selected{% endif %}>Escalated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Type
|
||||
</label>
|
||||
<select name="type" class="w-full form-select">
|
||||
<option value="">All Types</option>
|
||||
<option value="CREATE" {% if request.GET.type == 'CREATE' %}selected{% endif %}>New Submissions</option>
|
||||
<option value="UPDATE" {% if request.GET.type == 'UPDATE' %}selected{% endif %}>Edit Submissions</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Content Type
|
||||
</label>
|
||||
<select name="content_type" class="w-full form-select">
|
||||
<option value="">All Content</option>
|
||||
<option value="park" {% if request.GET.content_type == 'park' %}selected{% endif %}>Parks</option>
|
||||
<option value="ride" {% if request.GET.content_type == 'ride' %}selected{% endif %}>Rides</option>
|
||||
<option value="company" {% if request.GET.content_type == 'company' %}selected{% endif %}>Companies</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
129
backend/templates/moderation/partials/filters_store.html
Normal file
129
backend/templates/moderation/partials/filters_store.html
Normal file
@@ -0,0 +1,129 @@
|
||||
{% comment %}
|
||||
This template contains the Alpine.js store for managing filter state in the moderation dashboard
|
||||
{% endcomment %}
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('filters', {
|
||||
active: [],
|
||||
|
||||
init() {
|
||||
this.updateActiveFilters();
|
||||
|
||||
// Listen for filter changes
|
||||
window.addEventListener('filter-changed', () => {
|
||||
this.updateActiveFilters();
|
||||
});
|
||||
},
|
||||
|
||||
updateActiveFilters() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.active = [];
|
||||
|
||||
// Submission Type
|
||||
if (urlParams.has('submission_type')) {
|
||||
this.active.push({
|
||||
name: 'submission_type',
|
||||
label: 'Submission',
|
||||
value: this.getSubmissionTypeLabel(urlParams.get('submission_type'))
|
||||
});
|
||||
}
|
||||
|
||||
// Type
|
||||
if (urlParams.has('type')) {
|
||||
this.active.push({
|
||||
name: 'type',
|
||||
label: 'Type',
|
||||
value: this.getTypeLabel(urlParams.get('type'))
|
||||
});
|
||||
}
|
||||
|
||||
// Content Type
|
||||
if (urlParams.has('content_type')) {
|
||||
this.active.push({
|
||||
name: 'content_type',
|
||||
label: 'Content',
|
||||
value: this.getContentTypeLabel(urlParams.get('content_type'))
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getSubmissionTypeLabel(value) {
|
||||
const labels = {
|
||||
'text': 'Text',
|
||||
'photo': 'Photo'
|
||||
};
|
||||
return labels[value] || value;
|
||||
},
|
||||
|
||||
getTypeLabel(value) {
|
||||
const labels = {
|
||||
'CREATE': 'New',
|
||||
'EDIT': 'Edit'
|
||||
};
|
||||
return labels[value] || value;
|
||||
},
|
||||
|
||||
getContentTypeLabel(value) {
|
||||
const labels = {
|
||||
'park': 'Parks',
|
||||
'ride': 'Rides',
|
||||
'company': 'Companies'
|
||||
};
|
||||
return labels[value] || value;
|
||||
},
|
||||
|
||||
get hasActiveFilters() {
|
||||
return this.active.length > 0;
|
||||
},
|
||||
|
||||
clear() {
|
||||
const form = document.querySelector('form[hx-get]');
|
||||
if (form) {
|
||||
form.querySelectorAll('select').forEach(select => {
|
||||
select.value = '';
|
||||
});
|
||||
form.dispatchEvent(new Event('change'));
|
||||
}
|
||||
},
|
||||
|
||||
// Accessibility Helpers
|
||||
announceFilterChange() {
|
||||
const message = this.hasActiveFilters
|
||||
? `Applied filters: ${this.active.map(f => f.label + ': ' + f.value).join(', ')}`
|
||||
: 'All filters cleared';
|
||||
|
||||
const announcement = document.createElement('div');
|
||||
announcement.setAttribute('role', 'status');
|
||||
announcement.setAttribute('aria-live', 'polite');
|
||||
announcement.className = 'sr-only';
|
||||
announcement.textContent = message;
|
||||
|
||||
document.body.appendChild(announcement);
|
||||
setTimeout(() => announcement.remove(), 1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Watch for filter changes and update URL params
|
||||
document.addEventListener('filter-changed', (e) => {
|
||||
const form = e.target.closest('form');
|
||||
if (!form) return;
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (let [key, value] of formData.entries()) {
|
||||
if (value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Update URL without page reload
|
||||
const newUrl = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
|
||||
window.history.pushState({}, '', newUrl);
|
||||
|
||||
// Announce changes for screen readers
|
||||
Alpine.store('filters').announceFilterChange();
|
||||
});
|
||||
</script>
|
||||
66
backend/templates/moderation/partials/loading_skeleton.html
Normal file
66
backend/templates/moderation/partials/loading_skeleton.html
Normal file
@@ -0,0 +1,66 @@
|
||||
{% load static %}
|
||||
|
||||
<div class="animate-pulse">
|
||||
<!-- Filter Bar Skeleton -->
|
||||
<div class="flex items-center justify-between p-4 mb-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="flex items-center space-x-4">
|
||||
{% for i in "1234" %}
|
||||
<div class="w-24 h-10 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="w-24 h-10 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Form Skeleton -->
|
||||
<div class="p-6 mb-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
{% for i in "123" %}
|
||||
<div class="flex-1 min-w-[200px] space-y-2">
|
||||
<div class="w-24 h-4 bg-gray-200 rounded dark:bg-gray-700"></div>
|
||||
<div class="w-full h-10 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submission List Skeleton -->
|
||||
{% for i in "123" %}
|
||||
<div class="p-6 mb-4 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<!-- Left Column -->
|
||||
<div class="space-y-4 md:col-span-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-24 h-6 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{% for i in "1234" %}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-5 h-5 bg-gray-200 rounded dark:bg-gray-700"></div>
|
||||
<div class="w-32 h-4 bg-gray-200 rounded dark:bg-gray-700"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="md:col-span-2">
|
||||
{% for i in "12" %}
|
||||
<div class="p-4 mb-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="w-24 h-4 mb-2 bg-gray-200 rounded dark:bg-gray-700"></div>
|
||||
<div class="w-full h-4 bg-gray-200 rounded dark:bg-gray-700"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 mt-4 md:grid-cols-2">
|
||||
{% for i in "1234" %}
|
||||
<div class="p-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="w-24 h-4 mb-2 bg-gray-200 rounded dark:bg-gray-700"></div>
|
||||
<div class="w-full h-4 bg-gray-200 rounded dark:bg-gray-700"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
48
backend/templates/moderation/partials/location_map.html
Normal file
48
backend/templates/moderation/partials/location_map.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% load moderation_tags %}
|
||||
|
||||
<div class="p-4 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-300">Location</h3>
|
||||
|
||||
<!-- Map Container -->
|
||||
<div class="h-[300px] w-full rounded-lg border border-gray-300 dark:border-gray-600"
|
||||
id="viewMap-{{ submission.id }}"
|
||||
x-init="setTimeout(() => {
|
||||
const map = L.map('viewMap-{{ submission.id }}');
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map);
|
||||
|
||||
{% if submission.changes.latitude and submission.changes.longitude %}
|
||||
const lat = {{ submission.changes.latitude }};
|
||||
const lng = {{ submission.changes.longitude }};
|
||||
map.setView([lat, lng], 13);
|
||||
L.marker([lat, lng]).addTo(map);
|
||||
{% else %}
|
||||
map.setView([0, 0], 2);
|
||||
{% endif %}
|
||||
}, 100)"></div>
|
||||
|
||||
<!-- Address Display -->
|
||||
<div class="mt-4 space-y-1">
|
||||
{% if submission.changes.street_address %}
|
||||
<div class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<i class="w-5 mr-2 fas fa-map-marker-alt"></i>
|
||||
{{ submission.changes.street_address }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<i class="w-5 mr-2 fas fa-city"></i>
|
||||
{% if submission.changes.city %}{{ submission.changes.city }}{% endif %}
|
||||
{% if submission.changes.state %}, {{ submission.changes.state }}{% endif %}
|
||||
{% if submission.changes.postal_code %} {{ submission.changes.postal_code }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% if submission.changes.country %}
|
||||
<div class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<i class="w-5 mr-2 fas fa-globe"></i>
|
||||
{{ submission.changes.country }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
414
backend/templates/moderation/partials/location_widget.html
Normal file
414
backend/templates/moderation/partials/location_widget.html
Normal file
@@ -0,0 +1,414 @@
|
||||
{% load static %}
|
||||
|
||||
<style>
|
||||
/* Ensure map container and its elements stay below other UI elements */
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
.leaflet-control {
|
||||
z-index: 2 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="p-4 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-300">Location</h3>
|
||||
|
||||
<div class="location-widget" id="locationWidget-{{ submission.id }}">
|
||||
{# Search Form #}
|
||||
<div class="relative mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Search Location
|
||||
</label>
|
||||
<input type="text"
|
||||
id="locationSearch-{{ submission.id }}"
|
||||
class="relative w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
|
||||
placeholder="Search for a location..."
|
||||
autocomplete="off"
|
||||
style="z-index: 10;">
|
||||
<div id="searchResults-{{ submission.id }}"
|
||||
style="position: absolute; top: 100%; left: 0; right: 0; z-index: 1000;"
|
||||
class="hidden w-full mt-1 overflow-auto bg-white border rounded-md shadow-lg max-h-60 dark:bg-gray-700 dark:border-gray-600">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Map Container #}
|
||||
<div class="relative mb-4" style="z-index: 1;">
|
||||
<div id="locationMap-{{ submission.id }}"
|
||||
class="h-[300px] w-full rounded-lg border border-gray-300 dark:border-gray-600"></div>
|
||||
</div>
|
||||
|
||||
{# Location Form Fields #}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Street Address
|
||||
</label>
|
||||
<input type="text"
|
||||
name="street_address"
|
||||
id="streetAddress-{{ submission.id }}"
|
||||
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
|
||||
value="{{ submission.changes.street_address }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
City
|
||||
</label>
|
||||
<input type="text"
|
||||
name="city"
|
||||
id="city-{{ submission.id }}"
|
||||
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
|
||||
value="{{ submission.changes.city }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
State/Region
|
||||
</label>
|
||||
<input type="text"
|
||||
name="state"
|
||||
id="state-{{ submission.id }}"
|
||||
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
|
||||
value="{{ submission.changes.state }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Country
|
||||
</label>
|
||||
<input type="text"
|
||||
name="country"
|
||||
id="country-{{ submission.id }}"
|
||||
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
|
||||
value="{{ submission.changes.country }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Postal Code
|
||||
</label>
|
||||
<input type="text"
|
||||
name="postal_code"
|
||||
id="postalCode-{{ submission.id }}"
|
||||
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
|
||||
value="{{ submission.changes.postal_code }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Hidden Coordinate Fields #}
|
||||
<div class="hidden">
|
||||
<input type="hidden" name="latitude" id="latitude-{{ submission.id }}" value="{{ submission.changes.latitude }}">
|
||||
<input type="hidden" name="longitude" id="longitude-{{ submission.id }}" value="{{ submission.changes.longitude }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let maps = {};
|
||||
let markers = {};
|
||||
const searchInput = document.getElementById('locationSearch-{{ submission.id }}');
|
||||
const searchResults = document.getElementById('searchResults-{{ submission.id }}');
|
||||
let searchTimeout;
|
||||
|
||||
// Initialize form fields with existing values
|
||||
const fields = {
|
||||
city: '{{ submission.changes.city|default:"" }}',
|
||||
state: '{{ submission.changes.state|default:"" }}',
|
||||
country: '{{ submission.changes.country|default:"" }}',
|
||||
postal_code: '{{ submission.changes.postal_code|default:"" }}',
|
||||
street_address: '{{ submission.changes.street_address|default:"" }}',
|
||||
latitude: '{{ submission.changes.latitude|default:"" }}',
|
||||
longitude: '{{ submission.changes.longitude|default:"" }}'
|
||||
};
|
||||
|
||||
Object.entries(fields).forEach(([field, value]) => {
|
||||
const element = document.getElementById(`${field}-{{ submission.id }}`);
|
||||
if (element) {
|
||||
element.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial search input value if location exists
|
||||
if (fields.street_address || fields.city) {
|
||||
const parts = [
|
||||
fields.street_address,
|
||||
fields.city,
|
||||
fields.state,
|
||||
fields.country
|
||||
].filter(Boolean);
|
||||
searchInput.value = parts.join(', ');
|
||||
}
|
||||
|
||||
function normalizeCoordinate(value, maxDigits, decimalPlaces) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const rounded = Number(value).toFixed(decimalPlaces);
|
||||
const strValue = rounded.replace('.', '').replace('-', '');
|
||||
const strippedValue = strValue.replace(/0+$/, '');
|
||||
|
||||
if (strippedValue.length > maxDigits) {
|
||||
return Number(Number(value).toFixed(decimalPlaces - 1));
|
||||
}
|
||||
|
||||
return rounded;
|
||||
} catch (error) {
|
||||
console.error('Coordinate normalization failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function validateCoordinates(lat, lng) {
|
||||
const normalizedLat = normalizeCoordinate(lat, 9, 6);
|
||||
const normalizedLng = normalizeCoordinate(lng, 10, 6);
|
||||
|
||||
if (normalizedLat === null || normalizedLng === null) {
|
||||
throw new Error('Invalid coordinate format');
|
||||
}
|
||||
|
||||
const parsedLat = parseFloat(normalizedLat);
|
||||
const parsedLng = parseFloat(normalizedLng);
|
||||
|
||||
if (parsedLat < -90 || parsedLat > 90) {
|
||||
throw new Error('Latitude must be between -90 and 90 degrees.');
|
||||
}
|
||||
if (parsedLng < -180 || parsedLng > 180) {
|
||||
throw new Error('Longitude must be between -180 and 180 degrees.');
|
||||
}
|
||||
|
||||
return { lat: normalizedLat, lng: normalizedLng };
|
||||
}
|
||||
|
||||
function initMap() {
|
||||
const submissionId = '{{ submission.id }}';
|
||||
const mapId = `locationMap-${submissionId}`;
|
||||
const mapContainer = document.getElementById(mapId);
|
||||
|
||||
if (!mapContainer) {
|
||||
console.error(`Map container ${mapId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If map already exists, remove it
|
||||
if (maps[submissionId]) {
|
||||
maps[submissionId].remove();
|
||||
delete maps[submissionId];
|
||||
delete markers[submissionId];
|
||||
}
|
||||
|
||||
// Create new map
|
||||
maps[submissionId] = L.map(mapId);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(maps[submissionId]);
|
||||
|
||||
// Initialize with existing coordinates if available
|
||||
const initialLat = fields.latitude;
|
||||
const initialLng = fields.longitude;
|
||||
|
||||
if (initialLat && initialLng) {
|
||||
try {
|
||||
const normalized = validateCoordinates(initialLat, initialLng);
|
||||
maps[submissionId].setView([normalized.lat, normalized.lng], 13);
|
||||
addMarker(normalized.lat, normalized.lng);
|
||||
} catch (error) {
|
||||
console.error('Invalid initial coordinates:', error);
|
||||
maps[submissionId].setView([0, 0], 2);
|
||||
}
|
||||
} else {
|
||||
maps[submissionId].setView([0, 0], 2);
|
||||
}
|
||||
|
||||
// Handle map clicks
|
||||
maps[submissionId].on('click', async function(e) {
|
||||
try {
|
||||
const normalized = validateCoordinates(e.latlng.lat, e.latlng.lng);
|
||||
const response = await fetch(`/parks/search/reverse-geocode/?lat=${normalized.lat}&lon=${normalized.lng}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Geocoding request failed');
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
updateLocation(normalized.lat, normalized.lng, data);
|
||||
} catch (error) {
|
||||
console.error('Location update failed:', error);
|
||||
alert(error.message || 'Failed to update location. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addMarker(lat, lng) {
|
||||
const submissionId = '{{ submission.id }}';
|
||||
if (markers[submissionId]) {
|
||||
markers[submissionId].remove();
|
||||
}
|
||||
markers[submissionId] = L.marker([lat, lng]).addTo(maps[submissionId]);
|
||||
maps[submissionId].setView([lat, lng], 13);
|
||||
}
|
||||
|
||||
function updateLocation(lat, lng, data) {
|
||||
try {
|
||||
const normalized = validateCoordinates(lat, lng);
|
||||
const submissionId = '{{ submission.id }}';
|
||||
|
||||
// Update coordinates
|
||||
document.getElementById(`latitude-${submissionId}`).value = normalized.lat;
|
||||
document.getElementById(`longitude-${submissionId}`).value = normalized.lng;
|
||||
|
||||
// Update marker
|
||||
addMarker(normalized.lat, normalized.lng);
|
||||
|
||||
// Update form fields with English names where available
|
||||
const address = data.address || {};
|
||||
document.getElementById(`streetAddress-${submissionId}`).value =
|
||||
`${address.house_number || ''} ${address.road || address.street || ''}`.trim() || '';
|
||||
document.getElementById(`city-${submissionId}`).value =
|
||||
address.city || address.town || address.village || '';
|
||||
document.getElementById(`state-${submissionId}`).value =
|
||||
address.state || address.region || '';
|
||||
document.getElementById(`country-${submissionId}`).value = address.country || '';
|
||||
document.getElementById(`postalCode-${submissionId}`).value = address.postcode || '';
|
||||
|
||||
// Update search input
|
||||
const locationString-3 = [
|
||||
document.getElementById(`streetAddress-${submissionId}`).value,
|
||||
document.getElementById(`city-${submissionId}`).value,
|
||||
document.getElementById(`state-${submissionId}`).value,
|
||||
document.getElementById(`country-${submissionId}`).value
|
||||
].filter(Boolean).join(', ');
|
||||
searchInput.value = locationString;
|
||||
} catch (error) {
|
||||
console.error('Location update failed:', error);
|
||||
alert(error.message || 'Failed to update location. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function selectLocation(result) {
|
||||
if (!result) return;
|
||||
|
||||
try {
|
||||
const lat = parseFloat(result.lat);
|
||||
const lon = parseFloat(result.lon);
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
throw new Error('Invalid coordinates in search result');
|
||||
}
|
||||
|
||||
const normalized = validateCoordinates(lat, lon);
|
||||
|
||||
// Create a normalized address object
|
||||
const address = {
|
||||
name: result.display_name || result.name || '',
|
||||
address: {
|
||||
house_number: result.address ? result.address.house_number : '',
|
||||
road: result.address ? (result.address.road || result.address.street) : '',
|
||||
city: result.address ? (result.address.city || result.address.town || result.address.village) : '',
|
||||
state: result.address ? (result.address.state || result.address.region) : '',
|
||||
country: result.address ? result.address.country : '',
|
||||
postcode: result.address ? result.address.postcode : ''
|
||||
}
|
||||
};
|
||||
|
||||
updateLocation(normalized.lat, normalized.lng, address);
|
||||
searchResults.classList.add('hidden');
|
||||
searchInput.value = address.name;
|
||||
} catch (error) {
|
||||
console.error('Location selection failed:', error);
|
||||
alert(error.message || 'Failed to select location. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle location search
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = this.value.trim();
|
||||
|
||||
if (!query) {
|
||||
searchResults.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async function() {
|
||||
try {
|
||||
const response = await fetch(`/parks/search/location/?q=${encodeURIComponent(query)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Search request failed');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.results && data.results.length > 0) {
|
||||
const resultsHtml = data.results.map((result, index) => `
|
||||
<div class="p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
data-result-index="${index}">
|
||||
<div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
${(result.address && result.address.city) ? result.address.city + ', ' : ''}${(result.address && result.address.country) || ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
searchResults.innerHTML = resultsHtml;
|
||||
searchResults.classList.remove('hidden');
|
||||
|
||||
// Store results data
|
||||
searchResults.dataset.results = JSON.stringify(data.results);
|
||||
|
||||
// Add click handlers
|
||||
searchResults.querySelectorAll('[data-result-index]').forEach(el => {
|
||||
el.addEventListener('click', function() {
|
||||
const results = JSON.parse(searchResults.dataset.results);
|
||||
const result = results[this.dataset.resultIndex];
|
||||
selectLocation(result);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
searchResults.innerHTML = '<div class="p-2 text-gray-500 dark:text-gray-400">No results found</div>';
|
||||
searchResults.classList.remove('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
searchResults.innerHTML = '<div class="p-2 text-red-500 dark:text-red-400">Search failed. Please try again.</div>';
|
||||
searchResults.classList.remove('hidden');
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Hide search results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!searchResults.contains(e.target) && e.target !== searchInput) {
|
||||
searchResults.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize map when the element becomes visible
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
|
||||
const mapContainer = document.getElementById(`locationMap-{{ submission.id }}`);
|
||||
if (mapContainer && window.getComputedStyle(mapContainer).display !== 'none') {
|
||||
initMap();
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const mapContainer = document.getElementById(`locationMap-{{ submission.id }}`);
|
||||
if (mapContainer) {
|
||||
observer.observe(mapContainer.parentElement.parentElement, { attributes: true });
|
||||
|
||||
// Also initialize immediately if the container is already visible
|
||||
if (window.getComputedStyle(mapContainer).display !== 'none') {
|
||||
initMap();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<div class="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="selectManufacturerForSubmission('{{ manufacturer.id }}', '{{ manufacturer.name|escapejs }}', '{{ submission_id }}')">
|
||||
{{ manufacturer.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
|
||||
{% if search_term %}
|
||||
No manufacturers found
|
||||
{% else %}
|
||||
Start typing to search...
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectManufacturerForSubmission(id, name, submissionId) {
|
||||
// Debug logging
|
||||
console.log('Selecting manufacturer:', {id, name, submissionId});
|
||||
|
||||
// Find elements
|
||||
const manufacturerInput = document.querySelector(`#manufacturer-input-${submissionId}`);
|
||||
const searchInput = document.querySelector(`#manufacturer-search-${submissionId}`);
|
||||
const resultsDiv = document.querySelector(`#manufacturer-search-results-${submissionId}`);
|
||||
|
||||
// Debug logging
|
||||
console.log('Found elements:', {
|
||||
manufacturerInput: manufacturerInput?.id,
|
||||
searchInput: searchInput?.id,
|
||||
resultsDiv: resultsDiv?.id
|
||||
});
|
||||
|
||||
// Update hidden input
|
||||
if (manufacturerInput) {
|
||||
manufacturerInput.value = id;
|
||||
console.log('Updated manufacturer input value:', manufacturerInput.value);
|
||||
}
|
||||
|
||||
// Update search input
|
||||
if (searchInput) {
|
||||
searchInput.value = name;
|
||||
console.log('Updated search input value:', searchInput.value);
|
||||
}
|
||||
|
||||
// Clear results
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = '';
|
||||
console.log('Cleared results div');
|
||||
}
|
||||
}
|
||||
|
||||
// Close search results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const searchResults = document.querySelectorAll('[id^="manufacturer-search-results-"]');
|
||||
searchResults.forEach(function(resultsDiv) {
|
||||
const searchInput = document.querySelector(`#manufacturer-search-${resultsDiv.id.split('-').pop()}`);
|
||||
if (!resultsDiv.contains(e.target) && e.target !== searchInput) {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
43
backend/templates/moderation/partials/moderation_nav.html
Normal file
43
backend/templates/moderation/partials/moderation_nav.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{% load static %}
|
||||
|
||||
<div class="flex items-center p-4 space-x-4 bg-gray-900 rounded-lg">
|
||||
<a href="{% url 'moderation:submission_list' %}?status=NEW"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'NEW' or not request.GET.status %}bg-blue-900/40 text-blue-400{% else %}text-gray-400 hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=NEW"
|
||||
hx-target="body"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-clock"></i>
|
||||
<span>Pending</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=APPROVED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'APPROVED' %}bg-blue-900/40 text-blue-400{% else %}text-gray-400 hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=APPROVED"
|
||||
hx-target="body"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-check"></i>
|
||||
<span>Approved</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=REJECTED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'REJECTED' %}bg-blue-900/40 text-blue-400{% else %}text-gray-400 hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=REJECTED"
|
||||
hx-target="body"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-times"></i>
|
||||
<span>Rejected</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=ESCALATED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'ESCALATED' %}bg-blue-900/40 text-blue-400{% else %}text-gray-400 hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=ESCALATED"
|
||||
hx-target="body"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-exclamation-triangle"></i>
|
||||
<span>Escalated</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,73 @@
|
||||
<div class="w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
|
||||
{% if parks %}
|
||||
{% for park in parks %}
|
||||
<button type="button"
|
||||
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
|
||||
onclick="selectParkForSubmission('{{ park.id }}', '{{ park.name|escapejs }}', '{{ submission_id }}')">
|
||||
{{ park.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
|
||||
{% if search_term %}
|
||||
No parks found
|
||||
{% else %}
|
||||
Start typing to search...
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectParkForSubmission(id, name, submissionId) {
|
||||
// Debug logging
|
||||
console.log('Selecting park:', {id, name, submissionId});
|
||||
|
||||
// Find elements
|
||||
const parkInput = document.querySelector(`#park-input-${submissionId}`);
|
||||
const searchInput = document.querySelector(`#park-search-${submissionId}`);
|
||||
const resultsDiv = document.querySelector(`#park-search-results-${submissionId}`);
|
||||
|
||||
// Debug logging
|
||||
console.log('Found elements:', {
|
||||
parkInput: parkInput?.id,
|
||||
searchInput: searchInput?.id,
|
||||
resultsDiv: resultsDiv?.id
|
||||
});
|
||||
|
||||
// Update hidden input
|
||||
if (parkInput) {
|
||||
parkInput.value = id;
|
||||
console.log('Updated park input value:', parkInput.value);
|
||||
}
|
||||
|
||||
// Update search input
|
||||
if (searchInput) {
|
||||
searchInput.value = name;
|
||||
console.log('Updated search input value:', searchInput.value);
|
||||
}
|
||||
|
||||
// Clear results
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = '';
|
||||
console.log('Cleared results div');
|
||||
}
|
||||
|
||||
// Trigger park areas update
|
||||
if (parkInput) {
|
||||
htmx.trigger(parkInput, 'change');
|
||||
console.log('Triggered change event');
|
||||
}
|
||||
}
|
||||
|
||||
// Close search results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const searchResults = document.querySelectorAll('[id^="park-search-results-"]');
|
||||
searchResults.forEach(function(resultsDiv) {
|
||||
const searchInput = document.querySelector(`#park-search-${resultsDiv.id.split('-').pop()}`);
|
||||
if (!resultsDiv.contains(e.target) && e.target !== searchInput) {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
110
backend/templates/moderation/partials/photo_submission.html
Normal file
110
backend/templates/moderation/partials/photo_submission.html
Normal file
@@ -0,0 +1,110 @@
|
||||
<div class="p-6 submission-card" id="submission-{{ submission.id }}">
|
||||
<div class="mb-4 submission-header">
|
||||
<div>
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<span class="status-badge
|
||||
{% if submission.status == 'PENDING' %}status-pending
|
||||
{% elif submission.status == 'APPROVED' %}status-approved
|
||||
{% elif submission.status == 'REJECTED' %}status-rejected
|
||||
{% elif submission.status == 'ESCALATED' %}status-escalated{% endif %}">
|
||||
<i class="mr-1.5 fas fa-{% if submission.status == 'PENDING' %}clock
|
||||
{% elif submission.status == 'APPROVED' %}check
|
||||
{% elif submission.status == 'REJECTED' %}times
|
||||
{% elif submission.status == 'ESCALATED' %}exclamation{% endif %}"></i>
|
||||
{{ submission.get_status_display }}
|
||||
</span>
|
||||
Photo for {{ submission.content_object }}
|
||||
</h3>
|
||||
<div class="mt-1 submission-meta">
|
||||
<span class="inline-flex items-center">
|
||||
<i class="mr-1 fas fa-user"></i>
|
||||
{{ submission.user.username }}
|
||||
</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span class="inline-flex items-center">
|
||||
<i class="mr-1 fas fa-clock"></i>
|
||||
{{ submission.created_at|date:"M d, Y H:i" }}
|
||||
</span>
|
||||
{% if submission.date_taken %}
|
||||
<span class="mx-2">•</span>
|
||||
<span class="inline-flex items-center">
|
||||
<i class="mr-1 fas fa-calendar"></i>
|
||||
Taken: {{ submission.date_taken|date:"M d, Y" }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-hidden bg-gray-100 rounded-lg aspect-w-16 aspect-h-9 dark:bg-gray-800">
|
||||
<img src="{{ submission.photo.url }}"
|
||||
alt="{{ submission.caption|default:'Submitted photo' }}"
|
||||
class="object-contain w-full h-full">
|
||||
</div>
|
||||
|
||||
{% if submission.caption %}
|
||||
<div class="p-4 mt-2 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">Caption:</div>
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-400">{{ submission.caption }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if submission.notes %}
|
||||
<div class="p-4 mt-4 border rounded-lg bg-blue-50 dark:bg-blue-900/30 border-blue-200/50 dark:border-blue-700/50">
|
||||
<div class="text-sm font-medium text-blue-900 dark:text-blue-300">Review Notes:</div>
|
||||
<div class="mt-1.5 text-blue-800 dark:text-blue-200">{{ submission.notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if submission.status == 'PENDING' or submission.status == 'ESCALATED' and user.role in 'ADMIN,SUPERUSER' %}
|
||||
<div class="mt-4 review-notes" x-data="{ showNotes: false }">
|
||||
<textarea x-show="showNotes"
|
||||
name="notes"
|
||||
class="w-full p-3 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Add review notes (optional)"
|
||||
rows="3"></textarea>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 mt-4 action-buttons">
|
||||
<button class="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
@click="showNotes = !showNotes">
|
||||
<i class="mr-2 fas fa-comment-alt"></i>
|
||||
Add Notes
|
||||
</button>
|
||||
|
||||
{% if submission.status != 'ESCALATED' or user.role in 'ADMIN,SUPERUSER' %}
|
||||
<button class="btn-approve"
|
||||
hx-post="{% url 'moderation:approve_photo' submission.id %}"
|
||||
hx-target="#submission-{{ submission.id }}"
|
||||
hx-include="closest .review-notes"
|
||||
hx-confirm="Are you sure you want to approve this photo?"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2 fas fa-check"></i>
|
||||
Approve
|
||||
</button>
|
||||
|
||||
<button class="btn-reject"
|
||||
hx-post="{% url 'moderation:reject_photo' submission.id %}"
|
||||
hx-target="#submission-{{ submission.id }}"
|
||||
hx-include="closest .review-notes"
|
||||
hx-confirm="Are you sure you want to reject this photo?"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2 fas fa-times"></i>
|
||||
Reject
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if user.role == 'MODERATOR' and submission.status != 'ESCALATED' %}
|
||||
<button class="btn-escalate"
|
||||
hx-post="{% url 'moderation:escalate_photo' submission.id %}"
|
||||
hx-target="#submission-{{ submission.id }}"
|
||||
hx-include="closest .review-notes"
|
||||
hx-confirm="Are you sure you want to escalate this photo?"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2 fas fa-arrow-up"></i>
|
||||
Escalate
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
{% load static %}
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="p-6 bg-white border rounded-lg shadow-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Photo Submissions</h3>
|
||||
<span class="px-3 py-1 text-sm font-medium text-yellow-800 bg-yellow-100 rounded-full dark:bg-yellow-900/50 dark:text-yellow-200">
|
||||
{{ submissions|length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for submission in submissions %}
|
||||
{% include "moderation/partials/photo_submission.html" %}
|
||||
{% empty %}
|
||||
<div class="py-8 text-center col-span-full">
|
||||
<i class="mb-3 text-4xl text-gray-400 fas fa-camera"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400">No photo submissions found</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
<div class="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 ride_models %}
|
||||
{% for 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="selectRideModelForSubmission('{{ model.id }}', '{{ model.name|escapejs }}', '{{ submission_id }}')">
|
||||
{{ model.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
|
||||
{% if search_term %}
|
||||
No ride models found
|
||||
{% else %}
|
||||
Start typing to search...
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectRideModelForSubmission(id, name, submissionId) {
|
||||
// Debug logging
|
||||
console.log('Selecting ride model:', {id, name, submissionId});
|
||||
|
||||
// Find elements
|
||||
const modelInput = document.querySelector(`#ride-model-input-${submissionId}`);
|
||||
const searchInput = document.querySelector(`#ride-model-search-${submissionId}`);
|
||||
const resultsDiv = document.querySelector(`#ride-model-search-results-${submissionId}`);
|
||||
|
||||
// Debug logging
|
||||
console.log('Found elements:', {
|
||||
modelInput: modelInput?.id,
|
||||
searchInput: searchInput?.id,
|
||||
resultsDiv: resultsDiv?.id
|
||||
});
|
||||
|
||||
// Update hidden input
|
||||
if (modelInput) {
|
||||
modelInput.value = id;
|
||||
console.log('Updated ride model input value:', modelInput.value);
|
||||
}
|
||||
|
||||
// Update search input
|
||||
if (searchInput) {
|
||||
searchInput.value = name;
|
||||
console.log('Updated search input value:', searchInput.value);
|
||||
}
|
||||
|
||||
// Clear results
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = '';
|
||||
console.log('Cleared results div');
|
||||
}
|
||||
}
|
||||
|
||||
// Close search results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const searchResults = document.querySelectorAll('[id^="ride-model-search-results-"]');
|
||||
searchResults.forEach(function(resultsDiv) {
|
||||
const searchInput = document.querySelector(`#ride-model-search-${resultsDiv.id.split('-').pop()}`);
|
||||
if (!resultsDiv.contains(e.target) && e.target !== searchInput) {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
498
backend/templates/moderation/partials/submission_list.html
Normal file
498
backend/templates/moderation/partials/submission_list.html
Normal file
@@ -0,0 +1,498 @@
|
||||
{% load moderation_tags %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
{% endblock %}
|
||||
|
||||
{% for submission in submissions %}
|
||||
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50"
|
||||
id="submission-{{ submission.id }}"
|
||||
x-data="{
|
||||
showSuccess: false,
|
||||
isEditing: false,
|
||||
status: '{{ submission.changes.status|default:"" }}',
|
||||
category: '{{ submission.changes.category|default:"" }}',
|
||||
showCoasterFields: {% if submission.changes.category == 'RC' %}true{% else %}false{% endif %},
|
||||
init() {
|
||||
this.$watch('category', value => {
|
||||
this.showCoasterFields = value === 'RC';
|
||||
});
|
||||
}
|
||||
}"
|
||||
@htmx:afterOnLoad="showSuccess = true; setTimeout(() => showSuccess = false, 3000)">
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<!-- Left Column: Header & Status -->
|
||||
<div class="md:col-span-1">
|
||||
<div class="submission-header">
|
||||
<h3 class="flex items-center gap-3 text-lg font-semibold text-gray-900 dark:text-gray-300">
|
||||
<span class="status-badge
|
||||
{% if submission.status == 'PENDING' %}status-pending
|
||||
{% elif submission.status == 'APPROVED' %}status-approved
|
||||
{% elif submission.status == 'REJECTED' %}status-rejected
|
||||
{% elif submission.status == 'ESCALATED' %}status-escalated{% endif %}">
|
||||
<i class="mr-1.5 fas fa-{% if submission.status == 'PENDING' %}clock
|
||||
{% elif submission.status == 'APPROVED' %}check
|
||||
{% elif submission.status == 'REJECTED' %}times
|
||||
{% elif submission.status == 'ESCALATED' %}exclamation{% endif %}"></i>
|
||||
{{ submission.get_status_display }}
|
||||
</span>
|
||||
</h3>
|
||||
<div class="mt-3 text-gray-600 dark:text-gray-400">
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="w-5 mr-2 fas fa-file-alt"></i>
|
||||
{{ submission.get_content_type_display }} -
|
||||
{% if submission.submission_type == 'CREATE' %}New{% else %}Edit{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="w-5 mr-2 fas fa-user"></i>
|
||||
{{ submission.user.username }}
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="w-5 mr-2 fas fa-clock"></i>
|
||||
{{ submission.created_at|date:"M d, Y H:i" }}
|
||||
</div>
|
||||
{% if submission.handled_by %}
|
||||
<div class="flex items-center">
|
||||
<i class="w-5 mr-2 fas fa-user-shield"></i>
|
||||
{{ submission.handled_by.username }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Middle Column: Content Details -->
|
||||
<div class="md:col-span-2">
|
||||
{% if submission.content_object %}
|
||||
<div class="p-4 mb-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-300">Current Object:</div>
|
||||
<div class="mt-1.5 text-gray-600 dark:text-gray-400">
|
||||
{{ submission.content_object }}
|
||||
{% if submission.content_type.model == 'park' or submission.content_type.model == 'company' or submission.content_type.model == 'designer' %}
|
||||
<div class="mt-1 text-sm">
|
||||
{{ submission.content_object.address }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if submission.reason %}
|
||||
<div class="p-4 mb-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-300">Reason:</div>
|
||||
<div class="mt-1.5 text-gray-600 dark:text-gray-400">{{ submission.reason }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if submission.source %}
|
||||
<div class="p-4 mb-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-300">Source:</div>
|
||||
<div class="mt-1.5">
|
||||
<a href="{{ submission.source }}"
|
||||
target="_blank"
|
||||
class="inline-flex items-center text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
<span>{{ submission.source }}</span>
|
||||
<i class="ml-1.5 text-xs fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- View Mode -->
|
||||
<div x-show="!isEditing">
|
||||
<!-- Location Map (View Mode) -->
|
||||
{% if submission.content_type.model == 'park' %}
|
||||
<div class="mb-4">
|
||||
{% include "moderation/partials/location_map.html" with submission=submission %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{% for field, value in submission.changes.items %}
|
||||
{% if field != 'model_name' and field != 'latitude' and field != 'longitude' and field != 'street_address' and field != 'city' and field != 'state' and field != 'postal_code' and field != 'country' and field != 'location' %}
|
||||
<div class="p-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
{{ field|title }}:
|
||||
</div>
|
||||
<div class="mt-1.5 text-gray-600 dark:text-gray-400">
|
||||
{% if field == 'opening_date' or field == 'closing_date' or field == 'status_since' %}
|
||||
{{ value|date:"Y-m-d" }}
|
||||
{% elif field == 'size_acres' %}
|
||||
{{ value }} acres
|
||||
{% elif field == 'website' %}
|
||||
<a href="{{ value }}" target="_blank" class="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
{{ value }}
|
||||
</a>
|
||||
{% elif field == 'park' %}
|
||||
{% with park_name=value|get_object_name:'parks.Park' %}
|
||||
{{ park_name }}
|
||||
{% endwith %}
|
||||
{% elif field == 'designer' %}
|
||||
{% with designer_name=value|get_object_name:'designers.Designer' %}
|
||||
{{ designer_name|default:'None' }}
|
||||
{% endwith %}
|
||||
{% elif field == 'manufacturer' %}
|
||||
{% with manufacturer_name=value|get_object_name:'companies.Manufacturer' %}
|
||||
{{ manufacturer_name|default:'None' }}
|
||||
{% endwith %}
|
||||
{% elif field == 'ride_model' %}
|
||||
{% with model_name=value|get_object_name:'rides.RideModel' %}
|
||||
{{ model_name|default:'None' }}
|
||||
{% endwith %}
|
||||
{% elif field == 'park_area' %}
|
||||
{% with park_id=submission.changes.park %}
|
||||
{{ value|get_park_area_name:park_id|default:'None' }}
|
||||
{% endwith %}
|
||||
{% elif field == 'category' %}
|
||||
{{ value|get_category_display }}
|
||||
{% elif field == 'stats' %}
|
||||
<div class="space-y-2">
|
||||
{% if value.height_ft %}<div>Height: {{ value.height_ft }} ft</div>{% endif %}
|
||||
{% if value.length_ft %}<div>Length: {{ value.length_ft }} ft</div>{% endif %}
|
||||
{% if value.speed_mph %}<div>Speed: {{ value.speed_mph }} mph</div>{% endif %}
|
||||
{% if value.inversions %}<div>Inversions: {{ value.inversions }}</div>{% endif %}
|
||||
{% if value.launch_type %}<div>Launch Type: {{ value.launch_type }}</div>{% endif %}
|
||||
{% if value.track_material %}<div>Track Material: {{ value.track_material }}</div>{% endif %}
|
||||
{% if value.roller_coaster_type %}<div>Type: {{ value.roller_coaster_type }}</div>{% endif %}
|
||||
{% if value.trains_count %}<div>Number of Trains: {{ value.trains_count }}</div>{% endif %}
|
||||
{% if value.cars_per_train %}<div>Cars per Train: {{ value.cars_per_train }}</div>{% endif %}
|
||||
{% if value.seats_per_car %}<div>Seats per Car: {{ value.seats_per_car }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ value }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Mode -->
|
||||
<form x-show="isEditing"
|
||||
x-cloak
|
||||
hx-post="{% url 'moderation:edit_submission' submission.id %}"
|
||||
hx-target="#submission-{{ submission.id }}"
|
||||
class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
|
||||
<!-- Location Widget for Parks -->
|
||||
{% if submission.content_type.model == 'park' %}
|
||||
<div class="col-span-2">
|
||||
{% include "moderation/partials/location_widget.html" with submission=submission %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for field, value in submission.changes.items %}
|
||||
{% if field != 'model_name' and field != 'stats' and field != 'latitude' and field != 'longitude' and field != 'street_address' and field != 'city' and field != 'state' and field != 'postal_code' and field != 'country' and field != 'location' %}
|
||||
<div class="p-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50"
|
||||
{% if field == 'post_closing_status' %}x-show="status === 'CLOSING'"{% endif %}
|
||||
{% if field == 'closing_date' %}x-show="['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(status)"{% endif %}>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
{{ field|title }}:
|
||||
</label>
|
||||
|
||||
{% if field == 'category' %}
|
||||
<select name="{{ field }}"
|
||||
x-model="category"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Select ride type</option>
|
||||
<option value="RC" {% if value == 'RC' %}selected{% endif %}>Roller Coaster</option>
|
||||
<option value="DR" {% if value == 'DR' %}selected{% endif %}>Dark Ride</option>
|
||||
<option value="FR" {% if value == 'FR' %}selected{% endif %}>Flat Ride</option>
|
||||
<option value="WR" {% if value == 'WR' %}selected{% endif %}>Water Ride</option>
|
||||
<option value="TR" {% if value == 'TR' %}selected{% endif %}>Transport</option>
|
||||
<option value="OT" {% if value == 'OT' %}selected{% endif %}>Other</option>
|
||||
</select>
|
||||
{% elif field == 'status' %}
|
||||
<select name="{{ field }}"
|
||||
x-model="status"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
<option value="OPERATING" {% if value == 'OPERATING' %}selected{% endif %}>Operating</option>
|
||||
<option value="SBNO" {% if value == 'SBNO' %}selected{% endif %}>Standing But Not Operating</option>
|
||||
<option value="CLOSING" {% if value == 'CLOSING' %}selected{% endif %}>Closing</option>
|
||||
<option value="CLOSED_PERM" {% if value == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
|
||||
<option value="UNDER_CONSTRUCTION" {% if value == 'UNDER_CONSTRUCTION' %}selected{% endif %}>Under Construction</option>
|
||||
<option value="DEMOLISHED" {% if value == 'DEMOLISHED' %}selected{% endif %}>Demolished</option>
|
||||
<option value="RELOCATED" {% if value == 'RELOCATED' %}selected{% endif %}>Relocated</option>
|
||||
</select>
|
||||
{% elif field == 'post_closing_status' %}
|
||||
<select name="{{ field }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
<option value="SBNO" {% if value == 'SBNO' %}selected{% endif %}>Standing But Not Operating</option>
|
||||
<option value="CLOSED_PERM" {% if value == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
|
||||
</select>
|
||||
{% elif field == 'opening_date' or field == 'closing_date' or field == 'status_since' %}
|
||||
<input type="date"
|
||||
name="{{ field }}"
|
||||
value="{{ value|date:'Y-m-d' }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
{% if field == 'closing_date' %}
|
||||
:required="status === 'CLOSING'"
|
||||
{% endif %}>
|
||||
{% else %}
|
||||
{% if field == 'park' %}
|
||||
<div class="relative space-y-2">
|
||||
<input type="text"
|
||||
id="park-search-{{ submission.id }}"
|
||||
placeholder="Search for a park..."
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
hx-get="{% url 'moderation:search_parks' %}"
|
||||
hx-trigger="click, input delay:200ms"
|
||||
hx-target="#park-search-results-{{ submission.id }}"
|
||||
hx-vals='{"submission_id": "{{ submission.id }}"}'
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
{% with park_name=value|get_object_name:'parks.Park' %}
|
||||
value="{{ park_name }}"
|
||||
{% endwith %}>
|
||||
<div id="park-search-results-{{ submission.id }}" class="absolute z-50 w-full"></div>
|
||||
<input type="hidden"
|
||||
id="park-input-{{ submission.id }}"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}"
|
||||
hx-trigger="change"
|
||||
hx-get="/parks/areas/"
|
||||
hx-target="#park-area-select-{{ submission.id }}">
|
||||
</div>
|
||||
{% elif field == 'park_area' %}
|
||||
<select name="{{ field }}"
|
||||
id="park-area-select-{{ submission.id }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Select area</option>
|
||||
{% with park_id=submission.changes.park %}
|
||||
{% with areas=park_areas_by_park|get_item:park_id %}
|
||||
{% for area_id, area_name in areas %}
|
||||
<option value="{{ area_id }}" {% if value == area_id %}selected{% endif %}>{{ area_name }}</option>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</select>
|
||||
{% elif field == 'manufacturer' %}
|
||||
<div class="relative space-y-2">
|
||||
<input type="text"
|
||||
id="manufacturer-search-{{ submission.id }}"
|
||||
placeholder="Search for a manufacturer..."
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
hx-get="{% url 'moderation:search_manufacturers' %}"
|
||||
hx-trigger="click, input delay:200ms"
|
||||
hx-target="#manufacturer-search-results-{{ submission.id }}"
|
||||
hx-vals='{"submission_id": "{{ submission.id }}"}'
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
{% with manufacturer_name=value|get_object_name:'companies.Manufacturer' %}
|
||||
value="{{ manufacturer_name }}"
|
||||
{% endwith %}>
|
||||
<div id="manufacturer-search-results-{{ submission.id }}" class="absolute z-50 w-full"></div>
|
||||
<input type="hidden"
|
||||
id="manufacturer-input-{{ submission.id }}"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}">
|
||||
</div>
|
||||
{% elif field == 'designer' %}
|
||||
<div class="relative space-y-2">
|
||||
<input type="text"
|
||||
id="designer-search-{{ submission.id }}"
|
||||
placeholder="Search for a designer..."
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
hx-get="{% url 'moderation:search_designers' %}"
|
||||
hx-trigger="click, input delay:200ms"
|
||||
hx-target="#designer-search-results-{{ submission.id }}"
|
||||
hx-vals='{"submission_id": "{{ submission.id }}"}'
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
{% with designer_name=value|get_object_name:'designers.Designer' %}
|
||||
value="{{ designer_name }}"
|
||||
{% endwith %}>
|
||||
<div id="designer-search-results-{{ submission.id }}" class="absolute z-50 w-full"></div>
|
||||
<input type="hidden"
|
||||
id="designer-input-{{ submission.id }}"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}">
|
||||
</div>
|
||||
{% elif field == 'ride_model' %}
|
||||
<div class="relative space-y-2">
|
||||
<input type="text"
|
||||
id="ride-model-search-{{ submission.id }}"
|
||||
placeholder="Search for a ride model..."
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
hx-get="{% url 'moderation:search_ride_models' %}"
|
||||
hx-trigger="click, input delay:200ms"
|
||||
hx-target="#ride-model-search-results-{{ submission.id }}"
|
||||
hx-vals='{"submission_id": "{{ submission.id }}"}'
|
||||
hx-include="[name='manufacturer']"
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
{% with model_name=value|get_object_name:'rides.RideModel' %}
|
||||
value="{{ model_name }}"
|
||||
{% endwith %}>
|
||||
<div id="ride-model-search-results-{{ submission.id }}" class="absolute z-50 w-full"></div>
|
||||
<input type="hidden"
|
||||
id="ride-model-input-{{ submission.id }}"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}">
|
||||
</div>
|
||||
{% elif field == 'description' %}
|
||||
<textarea name="{{ field }}"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="General description and notable features">{{ value }}</textarea>
|
||||
{% elif field == 'min_height_in' or field == 'max_height_in' %}
|
||||
<input type="number"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Height in inches">
|
||||
{% elif field == 'capacity_per_hour' %}
|
||||
<input type="number"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Theoretical hourly ride capacity">
|
||||
{% elif field == 'ride_duration_seconds' %}
|
||||
<input type="number"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Total duration of one ride cycle in seconds">
|
||||
{% else %}
|
||||
<input type="text"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Coaster Fields -->
|
||||
<div x-show="showCoasterFields"
|
||||
x-cloak
|
||||
class="col-span-2">
|
||||
{% include 'moderation/partials/coaster_fields.html' with stats=submission.changes.stats %}
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 p-4 border rounded-lg bg-blue-50 dark:bg-blue-900/30 border-blue-200/50 dark:border-blue-700/50">
|
||||
<label class="block mb-2 text-sm font-medium text-blue-900 dark:text-blue-300">
|
||||
Notes (required):
|
||||
</label>
|
||||
<textarea name="notes"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg resize-none dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
rows="3"
|
||||
required
|
||||
placeholder="Explain why you're editing this submission"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end col-span-2 gap-3">
|
||||
<button type="button"
|
||||
class="px-4 py-2 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
@click="isEditing = false">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 font-medium text-white transition-all duration-200 bg-blue-600 rounded-lg hover:bg-blue-500 dark:bg-blue-700 dark:hover:bg-blue-600">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Keep existing review notes section -->
|
||||
{% if submission.notes %}
|
||||
<div class="p-4 mt-4 border rounded-lg bg-blue-50 dark:bg-blue-900/30 border-blue-200/50 dark:border-blue-700/50">
|
||||
<div class="text-sm font-medium text-blue-900 dark:text-blue-300">Review Notes:</div>
|
||||
<div class="mt-1.5 text-blue-800 dark:text-blue-200">{{ submission.notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if submission.status == 'PENDING' or submission.status == 'ESCALATED' and user.role in 'ADMIN,SUPERUSER' %}
|
||||
<div class="mt-6 review-notes" x-data="{ showNotes: false }">
|
||||
<div x-show="showNotes"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
||||
x-transition:leave-end="opacity-0 transform -translate-y-2">
|
||||
<textarea name="notes"
|
||||
class="w-full px-4 py-3 text-gray-900 bg-white border rounded-lg resize-none dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Add review notes (optional)"
|
||||
rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 action-buttons">
|
||||
<button class="inline-flex items-center px-4 py-2.5 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white shadow-xs hover:shadow-md"
|
||||
@click="showNotes = !showNotes">
|
||||
<i class="mr-2 fas fa-comment-alt"></i>
|
||||
Add Notes
|
||||
</button>
|
||||
|
||||
<button class="inline-flex items-center px-4 py-2.5 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white shadow-xs hover:shadow-md"
|
||||
@click="isEditing = !isEditing">
|
||||
<i class="mr-2 fas fa-edit"></i>
|
||||
Edit
|
||||
</button>
|
||||
|
||||
{% if submission.status != 'ESCALATED' or user.role in 'ADMIN,SUPERUSER' %}
|
||||
<button class="inline-flex items-center px-4 py-2.5 font-medium text-white transition-all duration-200 bg-green-600 rounded-lg hover:bg-green-500 dark:bg-green-700 dark:hover:bg-green-600 shadow-xs hover:shadow-md"
|
||||
hx-post="{% url 'moderation:approve_submission' submission.id %}"
|
||||
hx-target="#submissions-content"
|
||||
hx-include="closest .review-notes"
|
||||
hx-confirm="Are you sure you want to approve this submission?"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2 fas fa-check"></i>
|
||||
Approve
|
||||
</button>
|
||||
|
||||
<button class="inline-flex items-center px-4 py-2.5 font-medium text-white transition-all duration-200 bg-red-600 rounded-lg hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600 shadow-xs hover:shadow-md"
|
||||
hx-post="{% url 'moderation:reject_submission' submission.id %}"
|
||||
hx-target="#submissions-content"
|
||||
hx-include="closest .review-notes"
|
||||
hx-confirm="Are you sure you want to reject this submission?"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2 fas fa-times"></i>
|
||||
Reject
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if user.role == 'MODERATOR' and submission.status != 'ESCALATED' %}
|
||||
<button class="inline-flex items-center px-4 py-2.5 font-medium text-white transition-all duration-200 bg-yellow-600 rounded-lg hover:bg-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-600 shadow-xs hover:shadow-md"
|
||||
hx-post="{% url 'moderation:escalate_submission' submission.id %}"
|
||||
hx-target="#submissions-content"
|
||||
hx-include="closest .review-notes"
|
||||
hx-confirm="Are you sure you want to escalate this submission?"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2 fas fa-arrow-up"></i>
|
||||
Escalate
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="relative p-8 text-center bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="text-gray-600 dark:text-gray-400">
|
||||
<i class="mb-4 text-5xl fas fa-inbox"></i>
|
||||
<p class="text-lg">No submissions found matching your filters.</p>
|
||||
</div>
|
||||
|
||||
<div id="loading-indicator"
|
||||
class="absolute inset-0 flex items-center justify-center rounded-lg htmx-indicator bg-white/80 dark:bg-gray-900/80">
|
||||
<div class="flex items-center p-6 space-x-4">
|
||||
<div class="w-8 h-8 border-4 border-blue-500 rounded-full animate-spin border-t-transparent"></div>
|
||||
<span class="text-gray-900 dark:text-gray-300">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
{% endblock %}
|
||||
7
backend/templates/moderation/photo_submission_list.html
Normal file
7
backend/templates/moderation/photo_submission_list.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% extends "moderation/dashboard.html" %}
|
||||
|
||||
{% block moderation_content %}
|
||||
<div id="submissions-content">
|
||||
{% include "moderation/partials/photo_submission_content.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
70
backend/templates/pages/privacy.html
Normal file
70
backend/templates/pages/privacy.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Privacy Policy - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h1 class="text-3xl font-bold mb-6">Privacy Policy</h1>
|
||||
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
<h2>1. Information We Collect</h2>
|
||||
<p>We collect information that you provide directly to us, including:</p>
|
||||
<ul>
|
||||
<li>Account information (username, email, password)</li>
|
||||
<li>Profile information (name, avatar, location)</li>
|
||||
<li>Content you post (reviews, photos, comments)</li>
|
||||
<li>Communications with us</li>
|
||||
</ul>
|
||||
|
||||
<h2>2. How We Use Your Information</h2>
|
||||
<p>We use the information we collect to:</p>
|
||||
<ul>
|
||||
<li>Provide and maintain our services</li>
|
||||
<li>Process your transactions</li>
|
||||
<li>Send you technical notices and updates</li>
|
||||
<li>Respond to your comments and questions</li>
|
||||
<li>Understand how users interact with our service</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Information Sharing</h2>
|
||||
<p>We do not sell your personal information. We may share your information:</p>
|
||||
<ul>
|
||||
<li>With your consent</li>
|
||||
<li>To comply with legal obligations</li>
|
||||
<li>To protect our rights and safety</li>
|
||||
<li>With service providers who assist in our operations</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Data Security</h2>
|
||||
<p>We implement appropriate security measures to protect your personal information from unauthorized access, alteration, or destruction.</p>
|
||||
|
||||
<h2>5. Your Rights</h2>
|
||||
<p>You have the right to:</p>
|
||||
<ul>
|
||||
<li>Access your personal information</li>
|
||||
<li>Correct inaccurate information</li>
|
||||
<li>Request deletion of your information</li>
|
||||
<li>Object to processing of your information</li>
|
||||
<li>Export your data</li>
|
||||
</ul>
|
||||
|
||||
<h2>6. Cookies</h2>
|
||||
<p>We use cookies and similar technologies to:</p>
|
||||
<ul>
|
||||
<li>Keep you logged in</li>
|
||||
<li>Remember your preferences</li>
|
||||
<li>Understand how you use our service</li>
|
||||
<li>Improve our service</li>
|
||||
</ul>
|
||||
|
||||
<h2>7. Changes to Privacy Policy</h2>
|
||||
<p>We may update this privacy policy from time to time. We will notify you of any changes by posting the new policy on this page.</p>
|
||||
|
||||
<h2>8. Contact Us</h2>
|
||||
<p>If you have any questions about this privacy policy, please contact us at privacy@thrillwiki.com</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
41
backend/templates/pages/terms.html
Normal file
41
backend/templates/pages/terms.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Terms of Service - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h1 class="text-3xl font-bold mb-6">Terms of Service</h1>
|
||||
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
<h2>1. Acceptance of Terms</h2>
|
||||
<p>By accessing and using ThrillWiki, you accept and agree to be bound by the terms and provision of this agreement.</p>
|
||||
|
||||
<h2>2. User Content</h2>
|
||||
<p>Users are responsible for the content they submit to ThrillWiki. Content must be accurate, legal, and not infringe on any third party's rights.</p>
|
||||
|
||||
<h2>3. Account Responsibilities</h2>
|
||||
<p>You are responsible for maintaining the confidentiality of your account and password. You agree to accept responsibility for all activities that occur under your account.</p>
|
||||
|
||||
<h2>4. Prohibited Activities</h2>
|
||||
<p>Users must not engage in any activity that:</p>
|
||||
<ul>
|
||||
<li>Violates any laws or regulations</li>
|
||||
<li>Infringes on intellectual property rights</li>
|
||||
<li>Harasses or discriminates against others</li>
|
||||
<li>Spreads false or misleading information</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Content Ownership</h2>
|
||||
<p>Users retain ownership of their content but grant ThrillWiki a license to use, modify, and display the content on the platform.</p>
|
||||
|
||||
<h2>6. Modifications to Service</h2>
|
||||
<p>ThrillWiki reserves the right to modify or discontinue the service at any time without notice.</p>
|
||||
|
||||
<h2>7. Limitation of Liability</h2>
|
||||
<p>ThrillWiki is not liable for any indirect, incidental, special, consequential, or punitive damages.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
90
backend/templates/parks/area_detail.html
Normal file
90
backend/templates/parks/area_detail.html
Normal file
@@ -0,0 +1,90 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ area.name }} - {{ area.park.name }} - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="flex mb-4 text-sm" aria-label="Breadcrumb">
|
||||
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
||||
<li>
|
||||
<a href="{% url 'parks:park_detail' area.park.slug %}"
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
{{ area.park.name }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
<i class="mx-2 text-gray-400 fas fa-chevron-right"></i>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ area.name }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Area Header -->
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 lg:text-3xl dark:text-white">{{ area.name }}</h1>
|
||||
{% if user.is_authenticated %}
|
||||
<a href="#" class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-pencil-alt"></i>Edit
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if area.description %}
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
{{ area.description|linebreaks }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if area.opening_date or area.closing_date %}
|
||||
<div class="mt-4 space-y-2">
|
||||
{% if area.opening_date %}
|
||||
<div class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-calendar-plus"></i>
|
||||
<span>Opened: {{ area.opening_date }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if area.closing_date %}
|
||||
<div class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-2 fas fa-calendar-minus"></i>
|
||||
<span>Closed: {{ area.closing_date }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Rides in this Area -->
|
||||
{% if area.rides.exists %}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Rides & Attractions</h2>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for ride in area.rides.all %}
|
||||
<div class="p-4 transition-colors rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<a href="{% url 'parks:rides:ride_detail' area.park.slug ride.slug %}" class="block">
|
||||
<h3 class="mb-1 font-semibold text-gray-900 dark:text-white">{{ ride.name }}</h3>
|
||||
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50">
|
||||
{{ ride.get_category_display }}
|
||||
</span>
|
||||
{% if ride.average_rating %}
|
||||
<span class="flex items-center text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
|
||||
<span class="mr-1 text-yellow-500 dark:text-yellow-200">★</span>
|
||||
{{ ride.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<p class="text-gray-500 dark:text-gray-400">No rides or attractions listed in this area yet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
285
backend/templates/parks/park_detail.html
Normal file
285
backend/templates/parks/park_detail.html
Normal file
@@ -0,0 +1,285 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
{% load park_tags %}
|
||||
|
||||
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% if park.location.exists %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('photoUploadModal', () => ({
|
||||
show: false,
|
||||
editingPhoto: { caption: '' }
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Action Buttons - Above header -->
|
||||
<div hx-get="{% url 'parks:park_actions' park.slug %}"
|
||||
hx-trigger="load, auth-changed from:body"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
|
||||
<!-- Park Header -->
|
||||
<div class="p-compact mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white lg:text-4xl">{{ park.name }}</h1>
|
||||
{% if park.formatted_location %}
|
||||
<div class="flex items-center justify-center mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-1 fas fa-map-marker-alt"></i>
|
||||
<p>{{ park.formatted_location }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
|
||||
<span class="status-badge text-sm font-medium py-1 px-3 {% if park.status == 'OPERATING' %}status-operating
|
||||
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||
{% elif park.status == 'DEMOLISHED' %}status-demolished
|
||||
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
{% if park.average_rating %}
|
||||
<span class="flex items-center px-3 py-1 text-sm font-medium text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
|
||||
<span class="mr-1 text-yellow-500 dark:text-yellow-200">★</span>
|
||||
{{ park.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Horizontal Stats Bar -->
|
||||
<div class="grid-stats mb-6">
|
||||
<!-- Operator - Priority Card (First Position) -->
|
||||
{% if park.operator %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats card-stats-priority">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Operator</dt>
|
||||
<dd class="mt-1">
|
||||
<span class="text-sm font-bold text-sky-900 dark:text-sky-400">
|
||||
{{ park.operator.name }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Property Owner (if different from operator) -->
|
||||
{% if park.property_owner and park.property_owner != park.operator %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Property Owner</dt>
|
||||
<dd class="mt-1">
|
||||
<a href="{% url 'property_owners:property_owner_detail' park.property_owner.slug %}"
|
||||
class="text-sm font-bold text-sky-900 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-300">
|
||||
{{ park.property_owner.name }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Total Rides -->
|
||||
<a href="{% url 'parks:rides:ride_list' park.slug %}"
|
||||
class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats transition-transform hover:scale-[1.02]">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Total Rides</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-sky-900 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-300">{{ park.ride_count|default:"N/A" }}</dd>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Roller Coasters -->
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Roller Coasters</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-sky-900 dark:text-sky-400">{{ park.coaster_count|default:"N/A" }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Status</dt>
|
||||
<dd class="mt-1 text-sm font-bold text-sky-900 dark:text-sky-400">{{ park.get_status_display }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opened Date -->
|
||||
{% if park.opening_date %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Opened</dt>
|
||||
<dd class="mt-1 text-sm font-bold text-sky-900 dark:text-sky-400">{{ park.opening_date }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Website -->
|
||||
{% if park.website %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Website</dt>
|
||||
<dd class="mt-1">
|
||||
<a href="{{ park.website }}"
|
||||
class="inline-flex items-center text-sm font-bold text-sky-900 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-300"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
Visit
|
||||
<i class="ml-1 text-xs fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Rest of the content remains unchanged -->
|
||||
{% if park.photos.exists %}
|
||||
<div class="p-optimized mb-8 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
|
||||
{% include "media/partials/photo_display.html" with photos=park.photos.all content_type="parks.park" object_id=park.id %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Left Column - Description and Rides -->
|
||||
<div class="lg:col-span-2">
|
||||
{% if park.description %}
|
||||
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">About</h2>
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
{{ park.description|linebreaks }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Rides and Attractions -->
|
||||
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Rides & Attractions</h2>
|
||||
<a href="{% url 'parks:rides:ride_list' park.slug %}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
View All
|
||||
</a>
|
||||
</div>
|
||||
{% if park.rides.exists %}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
{% for ride in park.rides.all|slice:":6" %}
|
||||
<div class="p-4 transition-colors rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<a href="{% url 'parks:rides:ride_detail' park.slug ride.slug %}" class="block">
|
||||
<h3 class="mb-1 font-semibold text-gray-900 dark:text-white">{{ ride.name }}</h3>
|
||||
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50">
|
||||
{{ ride.get_category_display }}
|
||||
</span>
|
||||
{% if ride.average_rating %}
|
||||
<span class="flex items-center text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
|
||||
<span class="mr-1 text-yellow-500 dark:text-yellow-200">★</span>
|
||||
{{ ride.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 dark:text-gray-400">No rides or attractions listed yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column - Map and Additional Info -->
|
||||
<div class="lg:col-span-1">
|
||||
<!-- Location Map -->
|
||||
{% if park.location.exists %}
|
||||
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Location</h2>
|
||||
<div id="park-map" class="relative rounded-lg" style="z-index: 0;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- History Panel -->
|
||||
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">History</h2>
|
||||
<div class="space-y-4">
|
||||
{% for record in history %}
|
||||
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ record.history_date|date:"M d, Y H:i" }}
|
||||
{% if record.history_user %}
|
||||
by {{ record.history_user.username }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
{% for field, changes in record.diff_against_previous.items %}
|
||||
{% if field != "updated_at" %}
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">{{ field|title }}:</span>
|
||||
<span class="text-red-600 dark:text-red-400">{{ changes.old }}</span>
|
||||
<span class="mx-1">→</span>
|
||||
<span class="text-green-600 dark:text-green-400">{{ changes.new }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-gray-500">No history available.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Upload Modal -->
|
||||
{% if perms.media.add_photo %}
|
||||
<div x-cloak
|
||||
x-data="{
|
||||
show: false,
|
||||
editingPhoto: null,
|
||||
init() {
|
||||
this.editingPhoto = { caption: '' };
|
||||
}
|
||||
}"
|
||||
@show-photo-upload.window="show = true; init()"
|
||||
x-show="show"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50"
|
||||
@click.self="show = false">
|
||||
<div class="w-full max-w-2xl p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Upload Photos</h3>
|
||||
<button @click="show = false" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
<i class="text-xl fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% include "media/partials/photo_upload.html" with content_type="parks.park" object_id=park.id %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- Photo Gallery Script -->
|
||||
<script src="{% static 'js/photo-gallery.js' %}"></script>
|
||||
|
||||
<!-- Map Script (if location exists) -->
|
||||
{% if park.location.exists %}
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="{% static 'js/park-map.js' %}"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
{% with location=park.location.first %}
|
||||
initParkMap({{ location.latitude }}, {{ location.longitude }}, "{{ park.name }}");
|
||||
{% endwith %}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
321
backend/templates/parks/park_form.html
Normal file
321
backend/templates/parks/park_form.html
Normal file
@@ -0,0 +1,321 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<style>
|
||||
.photo-preview {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.photo-preview.uploading {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.photo-preview.error {
|
||||
border-color: red;
|
||||
}
|
||||
.photo-preview.success {
|
||||
border-color: green;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 py-8 mx-auto">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="mb-6 text-3xl font-bold">
|
||||
{% if is_edit %}Edit{% else %}Create{% endif %} Park
|
||||
</h1>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="parkForm()">
|
||||
{% csrf_token %}
|
||||
|
||||
{# Basic Information #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Basic Information</h2>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="col-span-2">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Name
|
||||
</label>
|
||||
{{ form.name }}
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Description
|
||||
</label>
|
||||
{{ form.description }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Owner/Operator
|
||||
</label>
|
||||
{{ form.owner }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status
|
||||
</label>
|
||||
{{ form.status }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Location #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Location</h2>
|
||||
{% include "parks/partials/location_widget.html" %}
|
||||
</div>
|
||||
|
||||
{# Photos #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Photos</h2>
|
||||
|
||||
{# Existing Photos #}
|
||||
{% if park.photos.exists %}
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for photo in park.photos.all %}
|
||||
<div class="relative overflow-hidden rounded-lg aspect-w-16 aspect-h-9">
|
||||
<img src="{{ photo.image.url }}"
|
||||
alt="{{ photo.caption|default:park.name }}"
|
||||
class="object-cover w-full h-full">
|
||||
<div class="absolute top-0 right-0 p-2">
|
||||
<button type="button"
|
||||
class="p-2 text-white bg-red-600 rounded-full hover:bg-red-700"
|
||||
@click="removePhoto('{{ photo.id }}')">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Photo Upload #}
|
||||
<div class="space-y-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Add Photos
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
x-ref="fileInput"
|
||||
@change="handleFileSelect">
|
||||
<button type="button"
|
||||
class="w-full px-4 py-2 text-gray-700 border-2 border-dashed rounded-lg dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
@click="$refs.fileInput.click()">
|
||||
<span x-show="!previews.length">
|
||||
<i class="mr-2 fas fa-upload"></i>
|
||||
Click to upload photos
|
||||
</span>
|
||||
<span x-show="previews.length">
|
||||
<i class="mr-2 fas fa-plus"></i>
|
||||
Add more photos
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Photo Previews #}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<template x-for="(preview, index) in previews" :key="preview.id">
|
||||
<div class="relative overflow-hidden transition-all duration-300 rounded-lg aspect-w-16 aspect-h-9 photo-preview"
|
||||
:class="{
|
||||
'uploading': preview.uploading,
|
||||
'error': preview.error,
|
||||
'success': preview.uploaded
|
||||
}">
|
||||
<img :src="preview.url"
|
||||
class="object-cover w-full h-full">
|
||||
<div class="absolute top-0 right-0 p-2">
|
||||
<button type="button"
|
||||
class="p-2 text-white bg-red-600 rounded-full hover:bg-red-700"
|
||||
@click="removePreview(index)">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="preview.uploading"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div class="w-16 h-16 border-4 border-blue-500 rounded-full animate-spin border-t-transparent"></div>
|
||||
</div>
|
||||
<div x-show="preview.error"
|
||||
class="absolute bottom-0 left-0 right-0 p-2 text-sm text-white bg-red-500">
|
||||
Upload failed
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Additional Details #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Additional Details</h2>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Opening Date
|
||||
</label>
|
||||
{{ form.opening_date }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Closing Date
|
||||
</label>
|
||||
{{ form.closing_date }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Operating Season
|
||||
</label>
|
||||
{{ form.operating_season }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Size (acres)
|
||||
</label>
|
||||
{{ form.size_acres }}
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Website
|
||||
</label>
|
||||
{{ form.website }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Submission Details #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Submission Details</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Reason for Changes
|
||||
</label>
|
||||
<textarea name="reason" rows="2"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Explain why you're making these changes"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Source
|
||||
</label>
|
||||
<input type="text" name="source"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Where did you get this information?">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Submit Button #}
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
class="px-6 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800"
|
||||
:disabled="uploading"
|
||||
x-text="uploading ? 'Uploading...' : '{% if is_edit %}Save Changes{% else %}Create Park{% endif %}'">
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
function parkForm() {
|
||||
return {
|
||||
previews: [],
|
||||
uploading: false,
|
||||
|
||||
handleFileSelect(event) {
|
||||
const files = event.target.files;
|
||||
if (!files.length) return;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file.type.startsWith('image/')) continue;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.previews.push({
|
||||
id: Date.now() + i,
|
||||
file: file,
|
||||
url: e.target.result,
|
||||
uploading: false,
|
||||
uploaded: false,
|
||||
error: false
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// Reset file input
|
||||
event.target.value = '';
|
||||
},
|
||||
|
||||
removePreview(index) {
|
||||
this.previews.splice(index, 1);
|
||||
},
|
||||
|
||||
async uploadPhotos() {
|
||||
if (!this.previews.length) return true;
|
||||
|
||||
this.uploading = true;
|
||||
let allUploaded = true;
|
||||
|
||||
for (let preview of this.previews) {
|
||||
if (preview.uploaded || preview.error) continue;
|
||||
|
||||
preview.uploading = true;
|
||||
const formData = new FormData();
|
||||
formData.append('image', preview.file);
|
||||
formData.append('app_label', 'parks');
|
||||
formData.append('model', 'park');
|
||||
formData.append('object_id', '{{ park.id }}');
|
||||
|
||||
try {
|
||||
const response = await fetch('/photos/upload/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
|
||||
const result = await response.json();
|
||||
preview.uploading = false;
|
||||
preview.uploaded = true;
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
preview.uploading = false;
|
||||
preview.error = true;
|
||||
allUploaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.uploading = false;
|
||||
return allUploaded;
|
||||
},
|
||||
|
||||
removePhoto(photoId) {
|
||||
if (confirm('Are you sure you want to remove this photo?')) {
|
||||
fetch(`/photos/${photoId}/delete/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||
},
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
5
backend/templates/parks/partials/add_park_button.html
Normal file
5
backend/templates/parks/partials/add_park_button.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% if user.is_authenticated %}
|
||||
<a href="{% url 'parks:park_create' %}" class="btn-primary">
|
||||
<i class="mr-2 fas fa-plus"></i>Add Park
|
||||
</a>
|
||||
{% endif %}
|
||||
356
backend/templates/parks/partials/location_widget.html
Normal file
356
backend/templates/parks/partials/location_widget.html
Normal file
@@ -0,0 +1,356 @@
|
||||
{% load static %}
|
||||
|
||||
<style>
|
||||
/* Ensure map container and its elements stay below other UI elements */
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
.leaflet-control {
|
||||
z-index: 2 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="location-widget" id="locationWidget">
|
||||
{# Search Form #}
|
||||
<div class="relative mb-4">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Search Location
|
||||
</label>
|
||||
<input type="text"
|
||||
id="locationSearch"
|
||||
class="relative w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Search for a location..."
|
||||
autocomplete="off"
|
||||
style="z-index: 10;">
|
||||
<div id="searchResults"
|
||||
style="position: absolute; top: 100%; left: 0; right: 0; z-index: 1000;"
|
||||
class="hidden w-full mt-1 overflow-auto bg-white border rounded-md shadow-lg max-h-60 dark:bg-gray-700 dark:border-gray-600">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Map Container #}
|
||||
<div class="relative mb-4" style="z-index: 1;">
|
||||
<div id="locationMap" class="h-[300px] w-full rounded-lg border border-gray-300 dark:border-gray-600"></div>
|
||||
</div>
|
||||
|
||||
{# Location Form Fields #}
|
||||
<div class="relative grid grid-cols-1 gap-4 md:grid-cols-2" style="z-index: 10;">
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Street Address
|
||||
</label>
|
||||
<input type="text"
|
||||
name="street_address"
|
||||
id="streetAddress"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value="{{ form.street_address.value|default:'' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
City
|
||||
</label>
|
||||
<input type="text"
|
||||
name="city"
|
||||
id="city"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value="{{ form.city.value|default:'' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
State/Region
|
||||
</label>
|
||||
<input type="text"
|
||||
name="state"
|
||||
id="state"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value="{{ form.state.value|default:'' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Country
|
||||
</label>
|
||||
<input type="text"
|
||||
name="country"
|
||||
id="country"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value="{{ form.country.value|default:'' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Postal Code
|
||||
</label>
|
||||
<input type="text"
|
||||
name="postal_code"
|
||||
id="postalCode"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
value="{{ form.postal_code.value|default:'' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Hidden Coordinate Fields #}
|
||||
<div class="hidden">
|
||||
<input type="hidden" name="latitude" id="latitude" value="{{ form.latitude.value|default:'' }}">
|
||||
<input type="hidden" name="longitude" id="longitude" value="{{ form.longitude.value|default:'' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let map = null;
|
||||
let marker = null;
|
||||
const searchInput = document.getElementById('locationSearch');
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
let searchTimeout;
|
||||
|
||||
function normalizeCoordinate(value, maxDigits, decimalPlaces) {
|
||||
try {
|
||||
// Convert to string-3 with exact decimal places
|
||||
const rounded = Number(value).toFixed(decimalPlaces);
|
||||
|
||||
// Convert to string-3 without decimal point for digit counting
|
||||
const strValue = rounded.replace('.', '').replace('-', '');
|
||||
// Remove trailing zeros
|
||||
const strippedValue = strValue.replace(/0+$/, '');
|
||||
|
||||
// If total digits exceed maxDigits, round further
|
||||
if (strippedValue.length > maxDigits) {
|
||||
return Number(Number(value).toFixed(decimalPlaces - 1));
|
||||
}
|
||||
|
||||
// Return the string-3 representation to preserve exact decimal places
|
||||
return rounded;
|
||||
} catch (error) {
|
||||
console.error('Coordinate normalization failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function validateCoordinates(lat, lng) {
|
||||
// Normalize coordinates
|
||||
const normalizedLat = normalizeCoordinate(lat, 9, 6);
|
||||
const normalizedLng = normalizeCoordinate(lng, 10, 6);
|
||||
|
||||
if (normalizedLat === null || normalizedLng === null) {
|
||||
throw new Error('Invalid coordinate format');
|
||||
}
|
||||
|
||||
const parsedLat = parseFloat(normalizedLat);
|
||||
const parsedLng = parseFloat(normalizedLng);
|
||||
|
||||
if (parsedLat < -90 || parsedLat > 90) {
|
||||
throw new Error('Latitude must be between -90 and 90 degrees.');
|
||||
}
|
||||
if (parsedLng < -180 || parsedLng > 180) {
|
||||
throw new Error('Longitude must be between -180 and 180 degrees.');
|
||||
}
|
||||
|
||||
return { lat: normalizedLat, lng: normalizedLng };
|
||||
}
|
||||
|
||||
// Initialize map
|
||||
function initMap() {
|
||||
map = L.map('locationMap').setView([0, 0], 2);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Initialize with existing coordinates if available
|
||||
const initialLat = document.getElementById('latitude').value;
|
||||
const initialLng = document.getElementById('longitude').value;
|
||||
if (initialLat && initialLng) {
|
||||
try {
|
||||
const normalized = validateCoordinates(initialLat, initialLng);
|
||||
addMarker(normalized.lat, normalized.lng);
|
||||
} catch (error) {
|
||||
console.error('Invalid initial coordinates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle map clicks
|
||||
map.on('click', async function(e) {
|
||||
try {
|
||||
const normalized = validateCoordinates(e.latlng.lat, e.latlng.lng);
|
||||
const response = await fetch(`/parks/search/reverse-geocode/?lat=${normalized.lat}&lon=${normalized.lng}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Geocoding request failed');
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
updateLocation(normalized.lat, normalized.lng, data);
|
||||
} catch (error) {
|
||||
console.error('Location update failed:', error);
|
||||
alert(error.message || 'Failed to update location. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize map
|
||||
initMap();
|
||||
|
||||
// Handle location search
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = this.value.trim();
|
||||
|
||||
if (!query) {
|
||||
searchResults.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async function() {
|
||||
try {
|
||||
const response = await fetch(`/parks/search/location/?q=${encodeURIComponent(query)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Search request failed');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.results && data.results.length > 0) {
|
||||
const resultsHtml = data.results.map((result, index) => `
|
||||
<div class="p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
data-result-index="${index}">
|
||||
<div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
${[
|
||||
result.street,
|
||||
result.city || (result.address && (result.address.city || result.address.town || result.address.village)),
|
||||
result.state || (result.address && (result.address.state || result.address.region)),
|
||||
result.country || (result.address && result.address.country),
|
||||
result.postal_code || (result.address && result.address.postcode)
|
||||
].filter(Boolean).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
searchResults.innerHTML = resultsHtml;
|
||||
searchResults.classList.remove('hidden');
|
||||
|
||||
// Store results data
|
||||
searchResults.dataset.results = JSON.stringify(data.results);
|
||||
|
||||
// Add click handlers
|
||||
searchResults.querySelectorAll('[data-result-index]').forEach(el => {
|
||||
el.addEventListener('click', function() {
|
||||
const results = JSON.parse(searchResults.dataset.results);
|
||||
const result = results[this.dataset.resultIndex];
|
||||
selectLocation(result);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
searchResults.innerHTML = '<div class="p-2 text-gray-500 dark:text-gray-400">No results found</div>';
|
||||
searchResults.classList.remove('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
searchResults.innerHTML = '<div class="p-2 text-red-500 dark:text-red-400">Search failed. Please try again.</div>';
|
||||
searchResults.classList.remove('hidden');
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Hide search results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!searchResults.contains(e.target) && e.target !== searchInput) {
|
||||
searchResults.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function addMarker(lat, lng) {
|
||||
if (marker) {
|
||||
marker.remove();
|
||||
}
|
||||
marker = L.marker([lat, lng]).addTo(map);
|
||||
map.setView([lat, lng], 13);
|
||||
}
|
||||
|
||||
function updateLocation(lat, lng, data) {
|
||||
try {
|
||||
const normalized = validateCoordinates(lat, lng);
|
||||
|
||||
// Update coordinates
|
||||
document.getElementById('latitude').value = normalized.lat;
|
||||
document.getElementById('longitude').value = normalized.lng;
|
||||
|
||||
// Update marker
|
||||
addMarker(normalized.lat, normalized.lng);
|
||||
|
||||
// Update form fields with English names where available
|
||||
const address = data.address || {};
|
||||
document.getElementById('streetAddress').value =
|
||||
`${address.house_number || ''} ${address.road || address.street || ''}`.trim() || '';
|
||||
document.getElementById('city').value =
|
||||
address.city || address.town || address.village || '';
|
||||
document.getElementById('state').value =
|
||||
address.state || address.region || '';
|
||||
document.getElementById('country').value = address.country || '';
|
||||
document.getElementById('postalCode').value = address.postcode || '';
|
||||
} catch (error) {
|
||||
console.error('Location update failed:', error);
|
||||
alert(error.message || 'Failed to update location. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function selectLocation(result) {
|
||||
if (!result) return;
|
||||
|
||||
try {
|
||||
const lat = parseFloat(result.lat);
|
||||
const lon = parseFloat(result.lon);
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
throw new Error('Invalid coordinates in search result');
|
||||
}
|
||||
|
||||
const normalized = validateCoordinates(lat, lon);
|
||||
|
||||
// Create a normalized address object
|
||||
const address = {
|
||||
name: result.display_name || result.name || '',
|
||||
address: {
|
||||
house_number: result.house_number || (result.address && result.address.house_number) || '',
|
||||
road: result.street || (result.address && (result.address.road || result.address.street)) || '',
|
||||
city: result.city || (result.address && (result.address.city || result.address.town || result.address.village)) || '',
|
||||
state: result.state || (result.address && (result.address.state || result.address.region)) || '',
|
||||
country: result.country || (result.address && result.address.country) || '',
|
||||
postcode: result.postal_code || (result.address && result.address.postcode) || ''
|
||||
}
|
||||
};
|
||||
|
||||
updateLocation(normalized.lat, normalized.lng, address);
|
||||
searchResults.classList.add('hidden');
|
||||
searchInput.value = address.name;
|
||||
} catch (error) {
|
||||
console.error('Location selection failed:', error);
|
||||
alert(error.message || 'Failed to select location. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// Add form submit handler
|
||||
const form = document.querySelector('form');
|
||||
form.addEventListener('submit', function(e) {
|
||||
const lat = document.getElementById('latitude').value;
|
||||
const lng = document.getElementById('longitude').value;
|
||||
|
||||
if (lat && lng) {
|
||||
try {
|
||||
validateCoordinates(lat, lng);
|
||||
} catch (error) {
|
||||
e.preventDefault();
|
||||
alert(error.message || 'Invalid coordinates. Please check the location.');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
37
backend/templates/parks/partials/park_actions.html
Normal file
37
backend/templates/parks/partials/park_actions.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% load park_tags %}
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<div class="flex justify-end gap-2 mb-2">
|
||||
<a href="{% url 'parks:park_update' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-pencil-alt"></i>Edit
|
||||
</a>
|
||||
{% include "rides/partials/add_ride_modal.html" %}
|
||||
{% if perms.media.add_photo %}
|
||||
<button class="transition-transform btn-secondary hover:scale-105"
|
||||
@click="$dispatch('show-photo-upload')">
|
||||
<i class="mr-1 fas fa-camera"></i>Upload Photo
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<!-- Add/Edit Review Button -->
|
||||
{% if not park.reviews.exists %}
|
||||
<a href="{% url 'reviews:add_review' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-star"></i>Add Review
|
||||
</a>
|
||||
{% else %}
|
||||
{% if user|has_reviewed_park:park %}
|
||||
<a href="{% url 'reviews:edit_review' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-star"></i>Edit Review
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'reviews:add_review' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-star"></i>Add Review
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
76
backend/templates/parks/partials/park_list.html
Normal file
76
backend/templates/parks/partials/park_list.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<!-- Parks Grid -->
|
||||
<div class="grid-adaptive">
|
||||
{% for park in parks %}
|
||||
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||
{% if park.photos.exists %}
|
||||
<div class="aspect-w-16 aspect-h-9">
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="object-cover w-full">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="p-4">
|
||||
<h2 class="mb-2 text-xl font-bold">
|
||||
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h2>
|
||||
{% if park.city or park.state or park.country %}
|
||||
<p class="mb-3 text-gray-600 dark:text-gray-400">
|
||||
<i class="mr-1 fas fa-map-marker-alt"></i>
|
||||
{% spaceless %}
|
||||
{% if park.city %}{{ park.city }}{% endif %}{% if park.city and park.state %}, {% endif %}{% if park.state %}{{ park.state }}{% endif %}{% if park.country and park.state or park.city %}, {% endif %}{% if park.country %}{{ park.country }}{% endif %}
|
||||
</p>
|
||||
{% endspaceless %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
|
||||
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||
{% elif park.status == 'DEMOLISHED' %}status-demolished
|
||||
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
{% if park.average_rating %}
|
||||
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
|
||||
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
|
||||
{{ park.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if park.operator %}
|
||||
<div class="mt-4 text-sm text-blue-600 dark:text-blue-400">
|
||||
{{ park.operator.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-span-3 py-8 text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">No parks found matching your criteria.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="inline-flex rounded-md shadow-xs">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page=1{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">« First</a>
|
||||
<a href="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
|
||||
<a href="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
27
backend/templates/parks/partials/park_search_results.html
Normal file
27
backend/templates/parks/partials/park_search_results.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<div class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
|
||||
{% if parks %}
|
||||
{% for park in parks %}
|
||||
<button type="button"
|
||||
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
|
||||
onclick="selectPark('{{ park.id }}', '{{ park.name|escapejs }}')">
|
||||
{{ park.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
|
||||
{% if search_term %}
|
||||
No parks found
|
||||
{% else %}
|
||||
Start typing to search...
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectPark(id, name) {
|
||||
document.getElementById('id_park').value = id;
|
||||
document.getElementById('id_park_search').value = name;
|
||||
document.getElementById('park-search-results').innerHTML = '';
|
||||
}
|
||||
</script>
|
||||
788
backend/templates/parks/roadtrip_planner.html
Normal file
788
backend/templates/parks/roadtrip_planner.html
Normal file
@@ -0,0 +1,788 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Road Trip Planner - ThrillWiki{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<!-- Leaflet Routing Machine CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.css" />
|
||||
|
||||
<style>
|
||||
.map-container {
|
||||
height: 70vh;
|
||||
min-height: 500px;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.park-selection-card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm hover:shadow-md transition-all cursor-pointer border-2 border-transparent;
|
||||
}
|
||||
|
||||
.park-selection-card:hover {
|
||||
@apply border-blue-200 dark:border-blue-700;
|
||||
}
|
||||
|
||||
.park-selection-card.selected {
|
||||
@apply border-blue-500 bg-blue-50 dark:bg-blue-900 dark:bg-opacity-30;
|
||||
}
|
||||
|
||||
.park-card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm;
|
||||
}
|
||||
|
||||
.trip-summary-card {
|
||||
@apply bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900 dark:to-indigo-900 rounded-lg p-4 shadow-sm;
|
||||
}
|
||||
|
||||
.waypoint-marker {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.waypoint-marker-inner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.waypoint-start .waypoint-marker-inner {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.waypoint-end .waypoint-marker-inner {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.waypoint-stop .waypoint-marker-inner {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.route-line {
|
||||
color: #3b82f6;
|
||||
weight: 4;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dark .route-line {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.trip-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.trip-stat {
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
.trip-stat-value {
|
||||
@apply text-2xl font-bold text-blue-600 dark:text-blue-400;
|
||||
}
|
||||
|
||||
.trip-stat-label {
|
||||
@apply text-sm text-gray-600 dark:text-gray-400 mt-1;
|
||||
}
|
||||
|
||||
.draggable-item {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.draggable-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.drag-over {
|
||||
@apply border-dashed border-2 border-blue-400 bg-blue-50 dark:bg-blue-900 dark:bg-opacity-30;
|
||||
}
|
||||
|
||||
.park-search-result {
|
||||
@apply p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.park-search-result:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.park-search-result:hover {
|
||||
@apply bg-gray-50 dark:bg-gray-700;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Road Trip Planner</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Plan the perfect theme park adventure across multiple destinations
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{% url 'maps:universal_map' %}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="mr-2 fas fa-globe"></i>View Map
|
||||
</a>
|
||||
<a href="{% url 'parks:park_list' %}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
<i class="mr-2 fas fa-list"></i>Browse Parks
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Left Panel - Trip Planning -->
|
||||
<div class="lg:col-span-1 space-y-6">
|
||||
<!-- Park Search -->
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Add Parks to Trip</h3>
|
||||
|
||||
<div class="relative">
|
||||
<input type="text" id="park-search"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Search parks by name or location..."
|
||||
hx-get="{% url 'parks:htmx_search_parks' %}"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#park-search-results"
|
||||
hx-indicator="#search-loading">
|
||||
|
||||
<div id="search-loading" class="htmx-indicator absolute right-3 top-3">
|
||||
<div class="w-4 h-4 border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 hidden">
|
||||
<!-- Search results will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trip Itinerary -->
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
|
||||
<button id="clear-trip"
|
||||
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
onclick="tripPlanner.clearTrip()">
|
||||
<i class="mr-1 fas fa-trash"></i>Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="trip-parks" class="space-y-2 min-h-20">
|
||||
<div id="empty-trip" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-route text-3xl mb-3"></i>
|
||||
<p>Add parks to start planning your trip</p>
|
||||
<p class="text-sm mt-1">Search above or click parks on the map</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<button id="optimize-route"
|
||||
class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="tripPlanner.optimizeRoute()" disabled>
|
||||
<i class="mr-2 fas fa-route"></i>Optimize Route
|
||||
</button>
|
||||
<button id="calculate-route"
|
||||
class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick="tripPlanner.calculateRoute()" disabled>
|
||||
<i class="mr-2 fas fa-map"></i>Calculate Route
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trip Summary -->
|
||||
<div id="trip-summary" class="trip-summary-card hidden">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Trip Summary</h3>
|
||||
|
||||
<div class="trip-stats">
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-distance">-</div>
|
||||
<div class="trip-stat-label">Total Miles</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-time">-</div>
|
||||
<div class="trip-stat-label">Drive Time</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-parks">-</div>
|
||||
<div class="trip-stat-label">Parks</div>
|
||||
</div>
|
||||
<div class="trip-stat">
|
||||
<div class="trip-stat-value" id="total-rides">-</div>
|
||||
<div class="trip-stat-label">Total Rides</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button id="save-trip"
|
||||
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
|
||||
onclick="tripPlanner.saveTrip()">
|
||||
<i class="mr-2 fas fa-save"></i>Save Trip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel - Map -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Route Map</h3>
|
||||
<div class="flex gap-2">
|
||||
<button id="fit-route"
|
||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
onclick="tripPlanner.fitRoute()">
|
||||
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
|
||||
</button>
|
||||
<button id="toggle-parks"
|
||||
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
onclick="tripPlanner.toggleAllParks()">
|
||||
<i class="mr-1 fas fa-eye"></i>Show All Parks
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="map-container" class="map-container"></div>
|
||||
|
||||
<!-- Map Loading Indicator -->
|
||||
<div id="map-loading" class="htmx-indicator absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<div class="text-center">
|
||||
<div class="w-8 h-8 mx-auto mb-4 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saved Trips Section -->
|
||||
<div class="mt-8">
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">My Saved Trips</h3>
|
||||
<button class="px-3 py-1 text-sm text-blue-600 hover:text-blue-700"
|
||||
hx-get="{% url 'parks:htmx_saved_trips' %}"
|
||||
hx-target="#saved-trips"
|
||||
hx-trigger="click">
|
||||
<i class="mr-1 fas fa-sync"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="saved-trips"
|
||||
hx-get="{% url 'parks:htmx_saved_trips' %}"
|
||||
hx-trigger="load"
|
||||
hx-indicator="#trips-loading">
|
||||
<!-- Saved trips will be loaded here -->
|
||||
</div>
|
||||
|
||||
<div id="trips-loading" class="htmx-indicator text-center py-4">
|
||||
<div class="w-6 h-6 mx-auto border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2">Loading saved trips...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- Leaflet JS -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<!-- Leaflet Routing Machine JS -->
|
||||
<script src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.js"></script>
|
||||
<!-- Sortable JS for drag & drop -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Road Trip Planner class
|
||||
class TripPlanner {
|
||||
constructor() {
|
||||
this.map = null;
|
||||
this.tripParks = [];
|
||||
this.allParks = [];
|
||||
this.parkMarkers = {};
|
||||
this.routeControl = null;
|
||||
this.showingAllParks = false;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.initMap();
|
||||
this.loadAllParks();
|
||||
this.initDragDrop();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
initMap() {
|
||||
// Initialize the map
|
||||
this.map = L.map('map-container', {
|
||||
center: [39.8283, -98.5795],
|
||||
zoom: 4,
|
||||
zoomControl: false
|
||||
});
|
||||
|
||||
// Add custom zoom control
|
||||
L.control.zoom({
|
||||
position: 'bottomright'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Add tile layers with dark mode support
|
||||
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
});
|
||||
|
||||
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors, © CARTO'
|
||||
});
|
||||
|
||||
// Set initial tiles based on theme
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
darkTiles.addTo(this.map);
|
||||
} else {
|
||||
lightTiles.addTo(this.map);
|
||||
}
|
||||
|
||||
// Listen for theme changes
|
||||
this.observeThemeChanges(lightTiles, darkTiles);
|
||||
}
|
||||
|
||||
observeThemeChanges(lightTiles, darkTiles) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
this.map.removeLayer(lightTiles);
|
||||
this.map.addLayer(darkTiles);
|
||||
} else {
|
||||
this.map.removeLayer(darkTiles);
|
||||
this.map.addLayer(lightTiles);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
}
|
||||
|
||||
async loadAllParks() {
|
||||
try {
|
||||
const response = await fetch('{{ map_api_urls.locations }}?types=park&limit=1000');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.data.locations) {
|
||||
this.allParks = data.data.locations;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load parks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
initDragDrop() {
|
||||
// Make trip parks sortable
|
||||
new Sortable(document.getElementById('trip-parks'), {
|
||||
animation: 150,
|
||||
ghostClass: 'drag-over',
|
||||
onEnd: (evt) => {
|
||||
this.reorderTripParks(evt.oldIndex, evt.newIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Handle park search results
|
||||
document.addEventListener('htmx:afterRequest', (event) => {
|
||||
if (event.target.id === 'park-search-results') {
|
||||
this.handleSearchResults();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleSearchResults() {
|
||||
const results = document.getElementById('park-search-results');
|
||||
if (results.children.length > 0) {
|
||||
results.classList.remove('hidden');
|
||||
} else {
|
||||
results.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
addParkToTrip(parkData) {
|
||||
// Check if park already in trip
|
||||
if (this.tripParks.find(p => p.id === parkData.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tripParks.push(parkData);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
// Hide search results
|
||||
document.getElementById('park-search-results').classList.add('hidden');
|
||||
document.getElementById('park-search').value = '';
|
||||
}
|
||||
|
||||
removeParkFromTrip(parkId) {
|
||||
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateTripDisplay() {
|
||||
const container = document.getElementById('trip-parks');
|
||||
const emptyState = document.getElementById('empty-trip');
|
||||
|
||||
if (this.tripParks.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
// Clear existing parks (except empty state)
|
||||
Array.from(container.children).forEach(child => {
|
||||
if (child.id !== 'empty-trip') {
|
||||
child.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Add trip parks
|
||||
this.tripParks.forEach((park, index) => {
|
||||
const parkElement = this.createTripParkElement(park, index);
|
||||
container.appendChild(parkElement);
|
||||
});
|
||||
}
|
||||
|
||||
createTripParkElement(park, index) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'park-card draggable-item';
|
||||
div.innerHTML = `
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold">
|
||||
${index + 1}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
${park.name}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||
${park.formatted_location || 'Location not specified'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
|
||||
class="text-red-500 hover:text-red-700 p-1">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<i class="fas fa-grip-vertical text-gray-400 cursor-grab"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return div;
|
||||
}
|
||||
|
||||
updateTripMarkers() {
|
||||
// Clear existing trip markers
|
||||
Object.values(this.parkMarkers).forEach(marker => {
|
||||
this.map.removeLayer(marker);
|
||||
});
|
||||
this.parkMarkers = {};
|
||||
|
||||
// Add markers for trip parks
|
||||
this.tripParks.forEach((park, index) => {
|
||||
const marker = this.createTripMarker(park, index);
|
||||
this.parkMarkers[park.id] = marker;
|
||||
marker.addTo(this.map);
|
||||
});
|
||||
|
||||
// Fit map to show all trip parks
|
||||
if (this.tripParks.length > 0) {
|
||||
this.fitRoute();
|
||||
}
|
||||
}
|
||||
|
||||
createTripMarker(park, index) {
|
||||
let markerClass = 'waypoint-stop';
|
||||
if (index === 0) markerClass = 'waypoint-start';
|
||||
if (index === this.tripParks.length - 1 && this.tripParks.length > 1) markerClass = 'waypoint-end';
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: `waypoint-marker ${markerClass}`,
|
||||
html: `<div class="waypoint-marker-inner">${index + 1}</div>`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15]
|
||||
});
|
||||
|
||||
const marker = L.marker([park.latitude, park.longitude], { icon });
|
||||
|
||||
const popupContent = `
|
||||
<div class="text-center">
|
||||
<h3 class="font-semibold mb-2">${park.name}</h3>
|
||||
<div class="text-sm text-gray-600 mb-2">Stop ${index + 1}</div>
|
||||
${park.ride_count ? `<div class="text-sm text-gray-600 mb-2">${park.ride_count} rides</div>` : ''}
|
||||
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
|
||||
class="px-3 py-1 text-sm text-red-600 border border-red-600 rounded hover:bg-red-50">
|
||||
Remove from Trip
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
return marker;
|
||||
}
|
||||
|
||||
reorderTripParks(oldIndex, newIndex) {
|
||||
const park = this.tripParks.splice(oldIndex, 1)[0];
|
||||
this.tripParks.splice(newIndex, 0, park);
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
|
||||
// Clear route to force recalculation
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
async optimizeRoute() {
|
||||
if (this.tripParks.length < 2) return;
|
||||
|
||||
try {
|
||||
const parkIds = this.tripParks.map(p => p.id);
|
||||
const response = await fetch('{% url "parks:htmx_optimize_route" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({ park_ids: parkIds })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.optimized_order) {
|
||||
// Reorder parks based on optimization
|
||||
const optimizedParks = data.optimized_order.map(id =>
|
||||
this.tripParks.find(p => p.id === id)
|
||||
).filter(Boolean);
|
||||
|
||||
this.tripParks = optimizedParks;
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Route optimization failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async calculateRoute() {
|
||||
if (this.tripParks.length < 2) return;
|
||||
|
||||
// Remove existing route
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
}
|
||||
|
||||
const waypoints = this.tripParks.map(park =>
|
||||
L.latLng(park.latitude, park.longitude)
|
||||
);
|
||||
|
||||
this.routeControl = L.Routing.control({
|
||||
waypoints: waypoints,
|
||||
routeWhileDragging: false,
|
||||
addWaypoints: false,
|
||||
createMarker: () => null, // Don't create default markers
|
||||
lineOptions: {
|
||||
styles: [{ color: '#3b82f6', weight: 4, opacity: 0.7 }]
|
||||
}
|
||||
}).addTo(this.map);
|
||||
|
||||
this.routeControl.on('routesfound', (e) => {
|
||||
const route = e.routes[0];
|
||||
this.updateTripSummary(route);
|
||||
});
|
||||
}
|
||||
|
||||
updateTripSummary(route) {
|
||||
if (!route) return;
|
||||
|
||||
const totalDistance = (route.summary.totalDistance / 1609.34).toFixed(1); // Convert to miles
|
||||
const totalTime = this.formatDuration(route.summary.totalTime);
|
||||
const totalRides = this.tripParks.reduce((sum, park) => sum + (park.ride_count || 0), 0);
|
||||
|
||||
document.getElementById('total-distance').textContent = totalDistance;
|
||||
document.getElementById('total-time').textContent = totalTime;
|
||||
document.getElementById('total-parks').textContent = this.tripParks.length;
|
||||
document.getElementById('total-rides').textContent = totalRides;
|
||||
|
||||
document.getElementById('trip-summary').classList.remove('hidden');
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
fitRoute() {
|
||||
if (this.tripParks.length === 0) return;
|
||||
|
||||
const group = new L.featureGroup(Object.values(this.parkMarkers));
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
|
||||
toggleAllParks() {
|
||||
// Implementation for showing/hiding all parks on the map
|
||||
const button = document.getElementById('toggle-parks');
|
||||
const icon = button.querySelector('i');
|
||||
|
||||
if (this.showingAllParks) {
|
||||
// Hide all parks
|
||||
this.showingAllParks = false;
|
||||
icon.className = 'mr-1 fas fa-eye';
|
||||
button.innerHTML = icon.outerHTML + 'Show All Parks';
|
||||
} else {
|
||||
// Show all parks
|
||||
this.showingAllParks = true;
|
||||
icon.className = 'mr-1 fas fa-eye-slash';
|
||||
button.innerHTML = icon.outerHTML + 'Hide All Parks';
|
||||
this.displayAllParks();
|
||||
}
|
||||
}
|
||||
|
||||
displayAllParks() {
|
||||
// Add markers for all parks (implementation depends on requirements)
|
||||
this.allParks.forEach(park => {
|
||||
if (!this.parkMarkers[park.id]) {
|
||||
const marker = L.marker([park.latitude, park.longitude], {
|
||||
icon: L.divIcon({
|
||||
className: 'location-marker location-marker-park',
|
||||
html: '<div class="location-marker-inner">🎢</div>',
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10]
|
||||
})
|
||||
});
|
||||
|
||||
marker.bindPopup(`
|
||||
<div class="text-center">
|
||||
<h3 class="font-semibold mb-2">${park.name}</h3>
|
||||
<button onclick="tripPlanner.addParkToTrip(${JSON.stringify(park).replace(/"/g, '"')})"
|
||||
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
|
||||
Add to Trip
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
marker.addTo(this.map);
|
||||
this.parkMarkers[`all_${park.id}`] = marker;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateButtons() {
|
||||
const optimizeBtn = document.getElementById('optimize-route');
|
||||
const calculateBtn = document.getElementById('calculate-route');
|
||||
|
||||
const hasEnoughParks = this.tripParks.length >= 2;
|
||||
|
||||
optimizeBtn.disabled = !hasEnoughParks;
|
||||
calculateBtn.disabled = !hasEnoughParks;
|
||||
}
|
||||
|
||||
clearTrip() {
|
||||
this.tripParks = [];
|
||||
this.updateTripDisplay();
|
||||
this.updateTripMarkers();
|
||||
this.updateButtons();
|
||||
|
||||
if (this.routeControl) {
|
||||
this.map.removeControl(this.routeControl);
|
||||
this.routeControl = null;
|
||||
}
|
||||
|
||||
document.getElementById('trip-summary').classList.add('hidden');
|
||||
}
|
||||
|
||||
async saveTrip() {
|
||||
if (this.tripParks.length === 0) return;
|
||||
|
||||
const tripName = prompt('Enter a name for this trip:');
|
||||
if (!tripName) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('{% url "parks:htmx_save_trip" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: tripName,
|
||||
park_ids: this.tripParks.map(p => p.id)
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
alert('Trip saved successfully!');
|
||||
// Refresh saved trips
|
||||
htmx.trigger('#saved-trips', 'refresh');
|
||||
} else {
|
||||
alert('Failed to save trip: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save trip failed:', error);
|
||||
alert('Failed to save trip');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global function for adding parks from search results
|
||||
window.addParkToTrip = function(parkData) {
|
||||
window.tripPlanner.addParkToTrip(parkData);
|
||||
};
|
||||
|
||||
// Initialize trip planner when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.tripPlanner = new TripPlanner();
|
||||
|
||||
// Hide search results when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#park-search') && !e.target.closest('#park-search-results')) {
|
||||
document.getElementById('park-search-results').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
139
backend/templates/rides/park_category_list.html
Normal file
139
backend/templates/rides/park_category_list.html
Normal 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: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">« First</a>
|
||||
<a href="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
|
||||
<a href="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
47
backend/templates/rides/partials/add_ride_modal.html
Normal file
47
backend/templates/rides/partials/add_ride_modal.html
Normal 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>
|
||||
110
backend/templates/rides/partials/coaster_fields.html
Normal file
110
backend/templates/rides/partials/coaster_fields.html
Normal 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>
|
||||
93
backend/templates/rides/partials/create_ride_model_form.html
Normal file
93
backend/templates/rides/partials/create_ride_model_form.html
Normal 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>
|
||||
26
backend/templates/rides/partials/designer_created.html
Normal file
26
backend/templates/rides/partials/designer_created.html
Normal 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 %}
|
||||
84
backend/templates/rides/partials/designer_form.html
Normal file
84
backend/templates/rides/partials/designer_form.html
Normal 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>
|
||||
@@ -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>
|
||||
30
backend/templates/rides/partials/history_panel.html
Normal file
30
backend/templates/rides/partials/history_panel.html
Normal 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>
|
||||
26
backend/templates/rides/partials/manufacturer_created.html
Normal file
26
backend/templates/rides/partials/manufacturer_created.html
Normal 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 %}
|
||||
84
backend/templates/rides/partials/manufacturer_form.html
Normal file
84
backend/templates/rides/partials/manufacturer_form.html
Normal 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>
|
||||
@@ -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>
|
||||
291
backend/templates/rides/partials/ride_form.html
Normal file
291
backend/templates/rides/partials/ride_form.html
Normal 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>
|
||||
63
backend/templates/rides/partials/ride_list.html
Normal file
63
backend/templates/rides/partials/ride_list.html
Normal 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 %}
|
||||
105
backend/templates/rides/partials/ride_list_results.html
Normal file
105
backend/templates/rides/partials/ride_list_results.html
Normal file
@@ -0,0 +1,105 @@
|
||||
{% load ride_tags %}
|
||||
|
||||
<!-- Rides Grid -->
|
||||
<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">
|
||||
<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 }}
|
||||
{% 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 %}
|
||||
</a>
|
||||
</div>
|
||||
<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 %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<div class="flex justify-center mt-6" hx-target="#ride-list-results" hx-push-url="false">
|
||||
<div class="inline-flex rounded-md shadow-xs">
|
||||
{% if page_obj.has_previous %}
|
||||
<a hx-get="?page=1{{ request.GET.urlencode }}"
|
||||
hx-target="#ride-list-results"
|
||||
class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">« First</a>
|
||||
<a hx-get="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}"
|
||||
hx-target="#ride-list-results"
|
||||
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 hx-get="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}"
|
||||
hx-target="#ride-list-results"
|
||||
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 hx-get="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}"
|
||||
hx-target="#ride-list-results"
|
||||
class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
44
backend/templates/rides/partials/ride_model_created.html
Normal file
44
backend/templates/rides/partials/ride_model_created.html
Normal 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 %}
|
||||
215
backend/templates/rides/partials/ride_model_form.html
Normal file
215
backend/templates/rides/partials/ride_model_form.html
Normal 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>
|
||||
@@ -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>
|
||||
416
backend/templates/rides/partials/search_script.html
Normal file
416
backend/templates/rides/partials/search_script.html
Normal 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>
|
||||
26
backend/templates/rides/partials/search_suggestions.html
Normal file
26
backend/templates/rides/partials/search_suggestions.html
Normal 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 %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user