This commit is contained in:
pacnpal
2025-09-21 20:04:42 -04:00
parent 42a3dc7637
commit 75cc618c2b
610 changed files with 1719 additions and 4816 deletions

0
templates/.gitkeep Normal file
View File

15
templates/404.html Normal file
View 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 %}

161
templates/404/home.html Normal file
View File

@@ -0,0 +1,161 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>PARK CLOSED</title>
<style>
:root{
--bg-wood:#6b4b2a;
--plank-dark:#5a3f28;
--plank-light:#8b5e3c;
--text:#f7f3ee;
--accent:#d9534f;
}
@media (prefers-color-scheme:dark){
:root{
--bg-wood:#2b1d14;
--plank-dark:#24170f;
--plank-light:#3b2a1f;
--text:#fff8f2;
--accent:#ff6b6b;
}
}
html,body{height:100%;margin:0;font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,"Helvetica Neue",Arial;}
/* Boarded-up background using repeating gradients to simulate planks and nails */
body{
min-height:100%;
display:flex;
align-items:center;
justify-content:center;
background:
radial-gradient(ellipse at 20% 10%, rgba(0,0,0,0.08) 0%, transparent 40%),
repeating-linear-gradient(90deg, var(--plank-dark) 0 60px, var(--plank-light) 60px 120px),
linear-gradient(180deg, rgba(0,0,0,0.18), rgba(0,0,0,0.02));
color:var(--text);
padding:24px;
}
.boarded {
width:100%;
max-width:1100px;
padding:48px;
border-radius:8px;
text-align:center;
position:relative;
box-shadow: 0 12px 40px rgba(2,6,23,0.45);
overflow:hidden;
}
/* darkened vignette to feel abandoned */
.boarded::before{
content:"";
position:absolute;
inset:0;
background:linear-gradient(180deg, rgba(0,0,0,0.12), rgba(0,0,0,0.32));
pointer-events:none;
}
/* Big roughed-up title */
h1 {
margin:0;
font-size:clamp(48px, 12vw, 180px);
line-height:0.9;
letter-spacing:6px;
font-weight:900;
text-transform:uppercase;
color:var(--text);
text-shadow:
0 2px 0 rgba(0,0,0,0.35),
0 8px 30px rgba(0,0,0,0.45);
transform:skewX(-6deg);
display:inline-block;
padding:12px 28px;
background:linear-gradient(180deg, rgba(0,0,0,0.12), rgba(255,255,255,0.02));
border-radius:6px;
box-decoration-break: clone;
}
/* Painted-on look for the board text */
.stencil {
position:relative;
display:inline-block;
color:var(--text);
background:
repeating-linear-gradient(
90deg,
rgba(0,0,0,0.12) 0 2px,
rgba(255,255,255,0.02) 2px 4px
);
-webkit-mask-image: linear-gradient(#000, #000);
}
p.lead{
margin:20px 0 0;
font-size:18px;
color:rgba(255,255,255,0.85);
}
.actions{
margin-top:26px;
display:flex;
gap:12px;
justify-content:center;
align-items:center;
flex-wrap:wrap;
}
.btn {
background:var(--accent);
color:white;
padding:12px 20px;
border-radius:8px;
text-decoration:none;
font-weight:700;
box-shadow:0 8px 20px rgba(0,0,0,0.32);
}
.link {
color:rgba(255,255,255,0.9);
text-decoration:underline;
font-weight:600;
}
/* decorative nails */
.nail{
width:14px;height:14px;border-radius:50%;
position:absolute;background:radial-gradient(circle at 35% 30%, #fff8, #0003 40%);
box-shadow:0 2px 6px rgba(0,0,0,0.6);
transform:translate(-50%,-50%);
mix-blend-mode:multiply;
opacity:0.9;
}
/* responsive smaller paddings */
@media (max-width:720px){
.boarded{padding:28px}
p.lead{font-size:15px}
}
</style>
</head>
<body>
<main class="boarded" role="main" aria-labelledby="page-title">
<!-- decorative nails placed around -->
<div class="nail" style="left:8%;top:12%;"></div>
<div class="nail" style="left:92%;top:14%;"></div>
<div class="nail" style="left:6%;top:86%;"></div>
<div class="nail" style="left:94%;top:84%;"></div>
<h1 id="page-title" class="stencil">PARK CLOSED</h1>
<p class="lead">We're sorry — this page is temporarily closed.</p>
<div class="actions" role="group" aria-label="navigation">
<a class="btn" href="/">Home</a>
<a class="link" href="/search/">Search</a>
<a class="link" href="/contact/">Contact Us</a>
</div>
</main>
</body>
</html>

15
templates/500.html Normal file
View 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 %}

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

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

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

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

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

View File

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

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

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

View File

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

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

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

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

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

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

View File

@@ -0,0 +1 @@
<!-- Empty template when DEBUG is True -->

View File

@@ -1,806 +1,141 @@
{% load static %}
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% block meta_description %}ThrillWiki - Your comprehensive guide to theme parks and roller coasters{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}theme parks, roller coasters, rides, amusement parks{% endblock %}">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
<meta property="og:url" content="{% block og_url %}{{ request.build_absolute_uri }}{% endblock %}">
<meta property="og:title" content="{% block og_title %}{% block title %}ThrillWiki{% endblock %}{% endblock %}">
<meta property="og:description" content="{% block og_description %}{% block meta_description %}ThrillWiki - Your comprehensive guide to theme parks and roller coasters{% endblock %}{% endblock %}">
<meta property="og:image" content="{% block og_image %}{% load static %}{% static 'images/og-default.jpg' %}{% endblock %}">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="{% block twitter_url %}{{ request.build_absolute_uri }}{% endblock %}">
<meta property="twitter:title" content="{% block twitter_title %}{% block title %}ThrillWiki{% endblock %}{% endblock %}">
<meta property="twitter:description" content="{% block twitter_description %}{% block meta_description %}ThrillWiki - Your comprehensive guide to theme parks and roller coasters{% endblock %}{% endblock %}">
<meta property="twitter:image" content="{% block twitter_image %}{% load static %}{% static 'images/twitter-default.jpg' %}{% endblock %}">
<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>
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="{% load static %}{% static 'favicon.ico' %}">
<link rel="apple-touch-icon" sizes="180x180" href="{% load static %}{% static 'apple-touch-icon.png' %}">
<link rel="icon" type="image/png" sizes="32x32" href="{% load static %}{% static 'favicon-32x32.png' %}">
<link rel="icon" type="image/png" sizes="16x16" href="{% load static %}{% static 'favicon-16x16.png' %}">
<!-- Preconnect to external domains -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- CSS -->
{% load static %}
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet">
{% block extra_css %}{% endblock %}
<!-- Design System CSS Variables -->
<style>
:root {
/* Primary Colors */
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
--color-primary-200: #bfdbfe;
--color-primary-300: #93c5fd;
--color-primary-400: #60a5fa;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-700: #1d4ed8;
--color-primary-800: #1e40af;
--color-primary-900: #1e3a8a;
--color-primary-950: #172554;
/* Secondary Colors */
--color-secondary-50: #f8fafc;
--color-secondary-100: #f1f5f9;
--color-secondary-200: #e2e8f0;
--color-secondary-300: #cbd5e1;
--color-secondary-400: #94a3b8;
--color-secondary-500: #64748b;
--color-secondary-600: #475569;
--color-secondary-700: #334155;
--color-secondary-800: #1e293b;
--color-secondary-900: #0f172a;
--color-secondary-950: #020617;
/* Accent Colors */
--color-accent-50: #fef2f2;
--color-accent-100: #fee2e2;
--color-accent-200: #fecaca;
--color-accent-300: #fca5a5;
--color-accent-400: #f87171;
--color-accent-500: #ef4444;
--color-accent-600: #dc2626;
--color-accent-700: #b91c1c;
--color-accent-800: #991b1b;
--color-accent-900: #7f1d1d;
--color-accent-950: #450a0a;
/* Success Colors */
--color-success-50: #f0fdf4;
--color-success-100: #dcfce7;
--color-success-200: #bbf7d0;
--color-success-300: #86efac;
--color-success-400: #4ade80;
--color-success-500: #22c55e;
--color-success-600: #16a34a;
--color-success-700: #15803d;
--color-success-800: #166534;
--color-success-900: #14532d;
--color-success-950: #052e16;
/* Warning Colors */
--color-warning-50: #fffbeb;
--color-warning-100: #fef3c7;
--color-warning-200: #fde68a;
--color-warning-300: #fcd34d;
--color-warning-400: #fbbf24;
--color-warning-500: #f59e0b;
--color-warning-600: #d97706;
--color-warning-700: #b45309;
--color-warning-800: #92400e;
--color-warning-900: #78350f;
--color-warning-950: #451a03;
/* Error Colors */
--color-error-50: #fef2f2;
--color-error-100: #fee2e2;
--color-error-200: #fecaca;
--color-error-300: #fca5a5;
--color-error-400: #f87171;
--color-error-500: #ef4444;
--color-error-600: #dc2626;
--color-error-700: #b91c1c;
--color-error-800: #991b1b;
--color-error-900: #7f1d1d;
--color-error-950: #450a0a;
/* Typography */
--font-family-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-family-serif: 'Playfair Display', Georgia, Cambria, 'Times New Roman', Times, serif;
--font-family-mono: 'JetBrains Mono', 'Fira Code', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
/* Font Sizes */
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
--font-size-5xl: 3rem;
--font-size-6xl: 3.75rem;
--font-size-7xl: 4.5rem;
--font-size-8xl: 6rem;
--font-size-9xl: 8rem;
/* Line Heights */
--line-height-none: 1;
--line-height-tight: 1.25;
--line-height-snug: 1.375;
--line-height-normal: 1.5;
--line-height-relaxed: 1.625;
--line-height-loose: 2;
/* Spacing */
--spacing-px: 1px;
--spacing-0: 0;
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-3: 0.75rem;
--spacing-4: 1rem;
--spacing-5: 1.25rem;
--spacing-6: 1.5rem;
--spacing-8: 2rem;
--spacing-10: 2.5rem;
--spacing-12: 3rem;
--spacing-16: 4rem;
--spacing-20: 5rem;
--spacing-24: 6rem;
--spacing-32: 8rem;
--spacing-40: 10rem;
--spacing-48: 12rem;
--spacing-56: 14rem;
--spacing-64: 16rem;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-base: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
--shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
/* Border Radius */
--radius-none: 0;
--radius-sm: 0.125rem;
--radius-base: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-2xl: 1rem;
--radius-3xl: 1.5rem;
--radius-full: 9999px;
/* Transitions */
--transition-none: none;
--transition-all: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-colors: color, background-color, border-color, text-decoration-color, fill, stroke 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-opacity: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-shadow: box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-transform: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
/* Z-Index */
--z-0: 0;
--z-10: 10;
--z-20: 20;
--z-30: 30;
--z-40: 40;
--z-50: 50;
--z-auto: auto;
}
/* Dark mode variables */
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--color-secondary-900);
--color-foreground: var(--color-secondary-50);
--color-muted: var(--color-secondary-800);
--color-muted-foreground: var(--color-secondary-400);
--color-border: var(--color-secondary-700);
--color-input: var(--color-secondary-800);
--color-card: var(--color-secondary-800);
--color-card-foreground: var(--color-secondary-50);
--color-popover: var(--color-secondary-800);
--color-popover-foreground: var(--color-secondary-50);
}
}
/* Light mode variables */
:root {
--color-background: var(--color-secondary-50);
--color-foreground: var(--color-secondary-900);
--color-muted: var(--color-secondary-100);
--color-muted-foreground: var(--color-secondary-500);
--color-border: var(--color-secondary-200);
--color-input: var(--color-secondary-50);
--color-card: var(--color-secondary-50);
--color-card-foreground: var(--color-secondary-900);
--color-popover: var(--color-secondary-50);
--color-popover-foreground: var(--color-secondary-900);
}
/* Dark mode override when .dark class is present */
.dark {
--color-background: var(--color-secondary-900);
--color-foreground: var(--color-secondary-50);
--color-muted: var(--color-secondary-800);
--color-muted-foreground: var(--color-secondary-400);
--color-border: var(--color-secondary-700);
--color-input: var(--color-secondary-800);
--color-card: var(--color-secondary-800);
--color-card-foreground: var(--color-secondary-50);
--color-popover: var(--color-secondary-800);
--color-popover-foreground: var(--color-secondary-50);
}
/* Base styles */
body {
font-family: var(--font-family-sans);
background-color: var(--color-background);
color: var(--color-foreground);
line-height: var(--line-height-normal);
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Focus styles for accessibility */
*:focus {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
}
/* Skip to content link for accessibility */
.skip-to-content {
position: absolute;
top: -40px;
left: 6px;
background: var(--color-primary-600);
color: white;
padding: 8px;
text-decoration: none;
border-radius: var(--radius-base);
z-index: 1000;
}
.skip-to-content:focus {
top: 6px;
}
/* Loading states */
.loading {
opacity: 0.6;
pointer-events: none;
}
/* HTMX indicators */
.htmx-indicator {
opacity: 0;
transition: var(--transition-opacity);
}
.htmx-request .htmx-indicator {
opacity: 1;
}
.htmx-request.htmx-indicator {
opacity: 1;
}
/* Animation utilities */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
.animate-slide-in-up {
animation: slideInUp 0.3s ease-out;
}
.animate-slide-in-down {
animation: slideInDown 0.3s ease-out;
}
</style>
</head>
<body class="h-full bg-background text-foreground antialiased"
x-data="{
darkMode: localStorage.getItem('darkMode') === 'true' || (!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches),
sidebarOpen: false,
searchOpen: false
}"
x-init="
$watch('darkMode', value => {
localStorage.setItem('darkMode', value);
if (value) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
});
if (darkMode) {
document.documentElement.classList.add('dark');
}
"
:class="{ 'dark': darkMode }">
<!-- Skip to content link for accessibility -->
<a href="#main-content" class="skip-to-content">Skip to main content</a>
<!-- HTMX Configuration -->
<div hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' style="display: none;"></div>
<!-- Page Layout -->
<div class="min-h-full">
<!-- Navigation -->
{% block navigation %}
<nav class="bg-card border-b border-border shadow-sm" role="navigation" aria-label="Main navigation">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<!-- Logo and primary navigation -->
<div class="flex">
<!-- Logo -->
<div class="flex-shrink-0 flex items-center">
<a href="{% url 'home' %}" class="flex items-center space-x-2 text-primary-600 hover:text-primary-700 transition-colors">
<svg class="h-8 w-8" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<span class="font-bold text-xl">ThrillWiki</span>
</a>
</div>
<!-- Primary navigation -->
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="{% url 'parks:park_list' %}"
class="border-transparent text-muted-foreground hover:border-primary-300 hover:text-foreground inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors">
Parks
</a>
<a href="{% url 'rides:ride_list' %}"
class="border-transparent text-muted-foreground hover:border-primary-300 hover:text-foreground inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors">
Rides
</a>
<a href="{% url 'manufacturers:manufacturer_list' %}"
class="border-transparent text-muted-foreground hover:border-primary-300 hover:text-foreground inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors">
Manufacturers
</a>
<a href="{% url 'operators:operator_list' %}"
class="border-transparent text-muted-foreground hover:border-primary-300 hover:text-foreground inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors">
Operators
</a>
</div>
</div>
<!-- Search, theme toggle, and user menu -->
<div class="flex items-center space-x-4">
<!-- Search button -->
<button @click="searchOpen = true"
class="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
aria-label="Open search">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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>
</button>
<!-- Theme toggle -->
<button @click="darkMode = !darkMode"
class="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
:aria-label="darkMode ? 'Switch to light mode' : 'Switch to dark mode'">
<svg x-show="!darkMode" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
<svg x-show="darkMode" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
</button>
<!-- User menu -->
{% if user.is_authenticated %}
<div class="relative" x-data="{ open: false }">
<button @click="open = !open"
class="flex items-center space-x-2 p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
aria-expanded="false" aria-haspopup="true">
<div class="h-6 w-6 bg-primary-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
{{ user.username|first|upper }}
</div>
<svg class="h-4 w-4" 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>
</button>
<div x-show="open"
@click.away="open = false"
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="absolute right-0 mt-2 w-48 bg-popover border border-border rounded-md shadow-lg z-50">
<div class="py-1">
<a href="{% url 'accounts:profile' %}"
class="block px-4 py-2 text-sm text-popover-foreground hover:bg-muted transition-colors">
Profile
</a>
<a href="{% url 'accounts:settings' %}"
class="block px-4 py-2 text-sm text-popover-foreground hover:bg-muted transition-colors">
Settings
</a>
{% if user.is_staff %}
<a href="{% url 'moderation:dashboard' %}"
class="block px-4 py-2 text-sm text-popover-foreground hover:bg-muted transition-colors">
Moderation
</a>
{% endif %}
<div class="border-t border-border my-1"></div>
<form method="post" action="{% url 'account_logout' %}" class="block">
{% csrf_token %}
<button type="submit"
class="w-full text-left px-4 py-2 text-sm text-popover-foreground hover:bg-muted transition-colors">
Sign out
</button>
</form>
</div>
</div>
</div>
{% else %}
<div class="flex items-center space-x-2">
<a href="{% url 'account_login' %}"
class="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
Sign in
</a>
<a href="{% url 'account_signup' %}"
class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
Sign up
</a>
</div>
{% endif %}
<!-- Mobile menu button -->
<button @click="sidebarOpen = true"
class="sm:hidden p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
aria-label="Open mobile menu">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
</div>
</div>
</nav>
{% endblock navigation %}
<!-- Mobile sidebar -->
<div x-show="sidebarOpen"
class="fixed inset-0 z-50 sm:hidden"
x-transition:enter="transition-opacity ease-linear duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-linear duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<!-- Backdrop -->
<div class="fixed inset-0 bg-black bg-opacity-25" @click="sidebarOpen = false"></div>
<!-- Sidebar -->
<div class="fixed inset-y-0 right-0 max-w-xs w-full bg-card shadow-xl"
x-transition:enter="transition ease-in-out duration-300 transform"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition ease-in-out duration-300 transform"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full">
<div class="flex flex-col h-full">
<div class="flex items-center justify-between p-4 border-b border-border">
<h2 class="text-lg font-semibold">Menu</h2>
<button @click="sidebarOpen = false"
class="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
aria-label="Close mobile menu">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<nav class="flex-1 px-4 py-6 space-y-2">
<a href="{% url 'parks:park_list' %}"
class="block px-3 py-2 text-base font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors">
Parks
</a>
<a href="{% url 'rides:ride_list' %}"
class="block px-3 py-2 text-base font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors">
Rides
</a>
<a href="{% url 'manufacturers:manufacturer_list' %}"
class="block px-3 py-2 text-base font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors">
Manufacturers
</a>
<a href="{% url 'operators:operator_list' %}"
class="block px-3 py-2 text-base font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors">
Operators
</a>
</nav>
</div>
</div>
</div>
<!-- Search Modal -->
<div x-show="searchOpen"
class="fixed inset-0 z-50"
x-transition:enter="transition-opacity ease-linear duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-linear duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<!-- Backdrop -->
<div class="fixed inset-0 bg-black bg-opacity-25" @click="searchOpen = false"></div>
<!-- Search Modal -->
<div class="fixed inset-x-0 top-0 max-w-2xl mx-auto mt-16 bg-card border border-border rounded-lg shadow-xl"
x-transition:enter="transition ease-out duration-300 transform"
x-transition:enter-start="opacity-0 scale-95 translate-y-4"
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
x-transition:leave="transition ease-in duration-200 transform"
x-transition:leave-start="opacity-100 scale-100 translate-y-0"
x-transition:leave-end="opacity-0 scale-95 translate-y-4">
<div class="p-4">
<div class="flex items-center space-x-3">
<svg class="h-5 w-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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>
<input type="text"
placeholder="Search parks, rides, manufacturers..."
class="flex-1 bg-transparent border-none outline-none text-foreground placeholder-muted-foreground"
hx-get="{% url 'search:global_search' %}"
hx-trigger="keyup changed delay:300ms"
hx-target="#search-results"
hx-indicator="#search-loading"
x-ref="searchInput">
<button @click="searchOpen = false"
class="p-1 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close search">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Search Results -->
<div class="mt-4 max-h-96 overflow-y-auto">
<div id="search-loading" class="htmx-indicator flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-500"></div>
</div>
<div id="search-results" class="space-y-2">
<!-- Search results will be loaded here via HTMX -->
</div>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<main id="main-content" class="flex-1" role="main">
<!-- Page Header -->
{% block page_header %}{% endblock %}
<!-- Breadcrumbs -->
{% block breadcrumbs %}{% endblock %}
<!-- Messages/Alerts -->
{% if messages %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
{% for message in messages %}
<div class="mb-4 p-4 rounded-md animate-slide-in-down
{% if message.tags == 'error' %}bg-error-50 border border-error-200 text-error-800 dark:bg-error-900/20 dark:border-error-800 dark:text-error-200
{% elif message.tags == 'warning' %}bg-warning-50 border border-warning-200 text-warning-800 dark:bg-warning-900/20 dark:border-warning-800 dark:text-warning-200
{% elif message.tags == 'success' %}bg-success-50 border border-success-200 text-success-800 dark:bg-success-900/20 dark:border-success-800 dark:text-success-200
{% else %}bg-primary-50 border border-primary-200 text-primary-800 dark:bg-primary-900/20 dark:border-primary-800 dark:text-primary-200
{% endif %}"
x-data="{ show: true }"
x-show="show"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="flex items-center justify-between">
<div class="flex items-center">
{% if message.tags == 'error' %}
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<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>
{% elif message.tags == 'warning' %}
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<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>
{% elif message.tags == 'success' %}
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<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>
{% else %}
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<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>
{% endif %}
<span>{{ message }}</span>
</div>
<button @click="show = false"
class="ml-4 text-current hover:opacity-75 transition-opacity"
aria-label="Dismiss message">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Page Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{% block content %}{% endblock %}
</div>
</main>
<!-- Footer -->
{% block footer %}
<footer class="bg-card border-t border-border mt-auto" role="contentinfo">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="col-span-1 md:col-span-2">
<div class="flex items-center space-x-2 text-primary-600 mb-4">
<svg class="h-8 w-8" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<span class="font-bold text-xl">ThrillWiki</span>
</div>
<p class="text-muted-foreground mb-4 max-w-md">
Your comprehensive guide to theme parks and roller coasters around the world.
Discover, explore, and share your passion for thrills.
</p>
<div class="flex space-x-4">
<a href="#" class="text-muted-foreground hover:text-foreground transition-colors" aria-label="Twitter">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"/>
</svg>
</a>
<a href="#" class="text-muted-foreground hover:text-foreground transition-colors" aria-label="GitHub">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
</svg>
</a>
</div>
</div>
<!-- Navigation -->
<div>
<h3 class="text-sm font-semibold text-foreground uppercase tracking-wider mb-4">Explore</h3>
<ul class="space-y-3">
<li><a href="{% url 'parks:park_list' %}" class="text-muted-foreground hover:text-foreground transition-colors">Parks</a></li>
<li><a href="{% url 'rides:ride_list' %}" class="text-muted-foreground hover:text-foreground transition-colors">Rides</a></li>
<li><a href="{% url 'manufacturers:manufacturer_list' %}" class="text-muted-foreground hover:text-foreground transition-colors">Manufacturers</a></li>
<li><a href="{% url 'operators:operator_list' %}" class="text-muted-foreground hover:text-foreground transition-colors">Operators</a></li>
</ul>
</div>
<!-- Legal -->
<div>
<h3 class="text-sm font-semibold text-foreground uppercase tracking-wider mb-4">Legal</h3>
<ul class="space-y-3">
<li><a href="{% url 'pages:privacy' %}" class="text-muted-foreground hover:text-foreground transition-colors">Privacy Policy</a></li>
<li><a href="{% url 'pages:terms' %}" class="text-muted-foreground hover:text-foreground transition-colors">Terms of Service</a></li>
</ul>
</div>
</div>
<div class="mt-8 pt-8 border-t border-border">
<p class="text-center text-muted-foreground text-sm">
© {{ current_year|default:"2024" }} ThrillWiki. All rights reserved.
</p>
</div>
</div>
</footer>
{% endblock footer %}
</div>
<!-- JavaScript -->
{% load static %}
<script src="{% static 'js/alpine.min.js' %}" defer></script>
<script src="{% static 'js/cdn.min.js' %}"></script>
<!-- HTMX Configuration -->
<!-- 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>
document.addEventListener('DOMContentLoaded', function() {
// Configure HTMX
htmx.config.globalViewTransitions = true;
htmx.config.useTemplateFragments = true;
// Add loading states
document.body.addEventListener('htmx:beforeRequest', function(evt) {
evt.target.classList.add('loading');
});
document.body.addEventListener('htmx:afterRequest', function(evt) {
evt.target.classList.remove('loading');
});
// Handle search modal focus
document.body.addEventListener('alpine:init', function() {
Alpine.data('searchModal', () => ({
open: false,
toggle() {
this.open = !this.open;
if (this.open) {
this.$nextTick(() => {
this.$refs.searchInput.focus();
});
}
}
}));
});
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
// Cmd/Ctrl + K to open search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
Alpine.store('search').toggle();
}
// Escape to close modals
if (e.key === 'Escape') {
// Close search modal
if (Alpine.store('search') && Alpine.store('search').open) {
Alpine.store('search').open = false;
}
}
});
});
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>
<!-- Alpine.js Components -->
<script src="{% static 'js/alpine-components.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/components.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"
>
<!-- Enhanced Header -->
{% include 'components/layout/enhanced_header.html' %}
<!-- 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>&copy; {% 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>
<!-- Global Auth Modal -->
{% include 'components/auth/auth-modal.html' %}
<!-- Global Toast Container -->
{% include 'components/ui/toast-container.html' %}
<!-- Custom JavaScript -->
<script src="{% static 'js/main.js' %}"></script>
<script src="{% static 'js/alerts.js' %}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
</body>
</html>

View File

@@ -0,0 +1,367 @@
{% comment %}
Enhanced Authentication Modal Component
Matches React frontend AuthDialog functionality with modal-based auth
{% endcomment %}
{% load static %}
{% load i18n %}
{% load account socialaccount %}
<!-- Auth Modal Component -->
<div
x-data="authModal()"
x-show="open"
x-cloak
x-init="window.authModal = $data"
class="fixed inset-0 z-50 flex items-center justify-center"
@keydown.escape.window="close()"
>
<!-- Modal Overlay -->
<div
x-show="open"
x-transition:enter="transition-opacity ease-linear duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-linear duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-background/80 backdrop-blur-sm"
@click="close()"
></div>
<!-- Modal Content -->
<div
x-show="open"
x-transition:enter="transition ease-out duration-300"
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-200"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="relative w-full max-w-md mx-4 bg-background border rounded-lg shadow-lg"
@click.stop
>
<!-- Close Button -->
<button
@click="close()"
class="absolute top-4 right-4 p-2 text-muted-foreground hover:text-foreground rounded-md hover:bg-accent transition-colors"
>
<i class="fas fa-times w-4 h-4"></i>
</button>
<!-- Login Form -->
<div x-show="mode === 'login'" class="p-6">
<div class="text-center mb-6">
<h2 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-700">
Sign In
</h2>
<p class="text-sm text-muted-foreground mt-2">
Enter your credentials to access your account
</p>
</div>
<!-- Social Login Buttons -->
<div x-show="socialProviders.length > 0" class="mb-6">
<div class="grid grid-cols-2 gap-4" x-show="!socialLoading">
<template x-for="provider in socialProviders" :key="provider.id">
<button
@click="handleSocialLogin(provider.id)"
class="flex items-center justify-center px-4 py-2 text-sm font-medium text-white rounded-md transition-colors"
:class="{
'bg-[#4285F4] hover:bg-[#357AE8]': provider.id === 'google',
'bg-[#5865F2] hover:bg-[#4752C4]': provider.id === 'discord',
'bg-primary hover:bg-primary/90': !['google', 'discord'].includes(provider.id)
}"
>
<i
class="mr-2 w-4 h-4"
:class="{
'fab fa-google': provider.id === 'google',
'fab fa-discord': provider.id === 'discord'
}"
></i>
<span x-text="provider.name"></span>
</button>
</template>
</div>
<div x-show="socialLoading" class="grid grid-cols-2 gap-4">
<div class="h-10 bg-muted animate-pulse rounded-md"></div>
<div class="h-10 bg-muted animate-pulse rounded-md"></div>
</div>
<!-- Divider -->
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-muted"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
</div>
<!-- Login Form -->
<form
@submit.prevent="handleLogin()"
class="space-y-4"
>
<div class="space-y-2">
<label for="login-username" class="text-sm font-medium">
Email or Username
</label>
<input
id="login-username"
type="text"
x-model="loginForm.username"
placeholder="Enter your email or username"
class="input w-full"
required
/>
</div>
<div class="space-y-2">
<label for="login-password" class="text-sm font-medium">
Password
</label>
<div class="relative">
<input
id="login-password"
:type="showPassword ? 'text' : 'password'"
x-model="loginForm.password"
placeholder="Enter your password"
class="input w-full pr-10"
required
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" class="w-4 h-4"></i>
</button>
</div>
</div>
<div class="flex items-center justify-between">
<a
href="{% url 'account_reset_password' %}"
class="text-sm text-primary hover:text-primary/80 underline-offset-4 hover:underline font-medium"
>
Forgot password?
</a>
</div>
<!-- Error Messages -->
<div x-show="loginError" class="p-3 text-sm text-destructive-foreground bg-destructive/10 border border-destructive/20 rounded-md">
<span x-text="loginError"></span>
</div>
<button
type="submit"
:disabled="loginLoading"
class="btn btn-default w-full bg-gradient-to-r from-blue-600 to-purple-700 hover:from-blue-700 hover:to-purple-800 text-white"
>
<span x-show="!loginLoading">Sign In</span>
<span x-show="loginLoading" class="flex items-center">
<i class="fas fa-spinner fa-spin mr-2"></i>
Signing in...
</span>
</button>
</form>
<!-- Switch to Register -->
<div class="text-center text-sm text-muted-foreground mt-6">
Don't have an account?
<button
@click="switchToRegister()"
class="text-primary hover:underline font-medium ml-1"
type="button"
>
Sign up
</button>
</div>
</div>
<!-- Register Form -->
<div x-show="mode === 'register'" class="p-6">
<div class="text-center mb-6">
<h2 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-700">
Create Account
</h2>
<p class="text-sm text-muted-foreground mt-2">
Join ThrillWiki to start exploring theme parks
</p>
</div>
<!-- Social Registration Buttons -->
<div x-show="socialProviders.length > 0" class="mb-6">
<div class="grid grid-cols-2 gap-4" x-show="!socialLoading">
<template x-for="provider in socialProviders" :key="provider.id">
<button
@click="handleSocialLogin(provider.id)"
class="flex items-center justify-center px-4 py-2 text-sm font-medium text-white rounded-md transition-colors"
:class="{
'bg-[#4285F4] hover:bg-[#357AE8]': provider.id === 'google',
'bg-[#5865F2] hover:bg-[#4752C4]': provider.id === 'discord',
'bg-primary hover:bg-primary/90': !['google', 'discord'].includes(provider.id)
}"
>
<i
class="mr-2 w-4 h-4"
:class="{
'fab fa-google': provider.id === 'google',
'fab fa-discord': provider.id === 'discord'
}"
></i>
<span x-text="provider.name"></span>
</button>
</template>
</div>
<!-- Divider -->
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-muted"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-background px-2 text-muted-foreground">
Or continue with email
</span>
</div>
</div>
</div>
<!-- Register Form -->
<form
@submit.prevent="handleRegister()"
class="space-y-4"
>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label for="register-first-name" class="text-sm font-medium">
First Name
</label>
<input
id="register-first-name"
type="text"
x-model="registerForm.first_name"
placeholder="First name"
class="input w-full"
required
/>
</div>
<div class="space-y-2">
<label for="register-last-name" class="text-sm font-medium">
Last Name
</label>
<input
id="register-last-name"
type="text"
x-model="registerForm.last_name"
placeholder="Last name"
class="input w-full"
required
/>
</div>
</div>
<div class="space-y-2">
<label for="register-email" class="text-sm font-medium">
Email
</label>
<input
id="register-email"
type="email"
x-model="registerForm.email"
placeholder="Enter your email"
class="input w-full"
required
/>
</div>
<div class="space-y-2">
<label for="register-username" class="text-sm font-medium">
Username
</label>
<input
id="register-username"
type="text"
x-model="registerForm.username"
placeholder="Choose a username"
class="input w-full"
required
/>
</div>
<div class="space-y-2">
<label for="register-password" class="text-sm font-medium">
Password
</label>
<div class="relative">
<input
id="register-password"
:type="showPassword ? 'text' : 'password'"
x-model="registerForm.password1"
placeholder="Create a password"
class="input w-full pr-10"
required
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" class="w-4 h-4"></i>
</button>
</div>
</div>
<div class="space-y-2">
<label for="register-password2" class="text-sm font-medium">
Confirm Password
</label>
<input
id="register-password2"
:type="showPassword ? 'text' : 'password'"
x-model="registerForm.password2"
placeholder="Confirm your password"
class="input w-full"
required
/>
</div>
<!-- Error Messages -->
<div x-show="registerError" class="p-3 text-sm text-destructive-foreground bg-destructive/10 border border-destructive/20 rounded-md">
<span x-text="registerError"></span>
</div>
<button
type="submit"
:disabled="registerLoading"
class="btn btn-default w-full bg-gradient-to-r from-blue-600 to-purple-700 hover:from-blue-700 hover:to-purple-800 text-white"
>
<span x-show="!registerLoading">Create Account</span>
<span x-show="registerLoading" class="flex items-center">
<i class="fas fa-spinner fa-spin mr-2"></i>
Creating account...
</span>
</button>
</form>
<!-- Switch to Login -->
<div class="text-center text-sm text-muted-foreground mt-6">
Already have an account?
<button
@click="switchToLogin()"
class="text-primary hover:underline font-medium ml-1"
type="button"
>
Sign in
</button>
</div>
</div>
</div>
</div>

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

View File

@@ -0,0 +1,448 @@
{% comment %}
Enhanced Header Component - Matches React Frontend Design
Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
{% endcomment %}
{% load static %}
<header class="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="flex h-14 items-center justify-between px-4 max-w-full">
<!-- Logo and Browse Menu -->
<div class="flex items-center space-x-6">
<!-- Logo -->
<a href="{% url 'home' %}" class="flex items-center space-x-2 flex-shrink-0">
<div class="w-6 h-6 bg-purple-600 rounded flex items-center justify-center">
<span class="text-white text-xs font-bold">TW</span>
</div>
<span class="font-bold text-lg">ThrillWiki</span>
</a>
<!-- Browse Menu (Desktop) -->
<div class="hidden md:block">
<div
x-data="{ open: false }"
@mouseenter="open = true"
@mouseleave="open = false"
class="relative"
>
<button
class="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent transition-colors"
@click="open = !open"
>
<i class="fas fa-compass w-4 h-4"></i>
Browse
<i class="fas fa-chevron-down w-4 h-4"></i>
</button>
<!-- Browse Dropdown -->
<div
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"
x-cloak
class="absolute left-0 mt-2 w-[480px] p-6 bg-background border rounded-lg shadow-lg z-50"
>
<div class="grid grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<a
href="{% url 'parks:park_list' %}"
class="flex items-start gap-3 p-3 rounded-lg hover:bg-accent transition-colors group"
@click="open = false"
>
<i class="fas fa-map-marker-alt w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground"></i>
<div>
<h3 class="font-semibold text-sm mb-1">Parks</h3>
<p class="text-xs text-muted-foreground">Explore theme parks worldwide</p>
</div>
</a>
<a
href="{% url 'rides:manufacturer_list' %}"
class="flex items-start gap-3 p-3 rounded-lg hover:bg-accent transition-colors group"
@click="open = false"
>
<i class="fas fa-wrench w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground"></i>
<div>
<h3 class="font-semibold text-sm mb-1">Manufacturers</h3>
<p class="text-xs text-muted-foreground">Ride and attraction manufacturers</p>
</div>
</a>
<a
href="{% url 'parks:operator_list' %}"
class="flex items-start gap-3 p-3 rounded-lg hover:bg-accent transition-colors group"
@click="open = false"
>
<i class="fas fa-users w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground"></i>
<div>
<h3 class="font-semibold text-sm mb-1">Operators</h3>
<p class="text-xs text-muted-foreground">Theme park operating companies</p>
</div>
</a>
</div>
<!-- Right Column -->
<div class="space-y-4">
<a
href="{% url 'rides:global_ride_list' %}"
class="flex items-start gap-3 p-3 rounded-lg hover:bg-accent transition-colors group"
@click="open = false"
>
<i class="fas fa-rocket w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground"></i>
<div>
<h3 class="font-semibold text-sm mb-1">Rides</h3>
<p class="text-xs text-muted-foreground">Discover rides and attractions</p>
</div>
</a>
<a
href="{% url 'rides:designer_list' %}"
class="flex items-start gap-3 p-3 rounded-lg hover:bg-accent transition-colors group"
@click="open = false"
>
<i class="fas fa-drafting-compass w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground"></i>
<div>
<h3 class="font-semibold text-sm mb-1">Designers</h3>
<p class="text-xs text-muted-foreground">Ride designers and architects</p>
</div>
</a>
<a
href="#"
class="flex items-start gap-3 p-3 rounded-lg hover:bg-accent transition-colors group"
@click="open = false"
>
<i class="fas fa-trophy w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground"></i>
<div>
<h3 class="font-semibold text-sm mb-1">Top Lists</h3>
<p class="text-xs text-muted-foreground">Community rankings and favorites</p>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Desktop Right Side -->
<div class="hidden md:flex items-center space-x-4">
<!-- Enhanced Search -->
<div class="relative" x-data="searchComponent()">
<div class="relative">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"></i>
<input
type="search"
placeholder="Search parks, rides..."
class="w-[300px] pl-10 pr-20 h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
x-model="query"
@input.debounce.300ms="search()"
hx-get="{% url 'search:search' %}"
hx-trigger="input changed delay:300ms"
hx-target="#search-results"
hx-include="this"
name="q"
/>
{% include 'components/ui/button.html' with variant='default' size='sm' text='Search' class='absolute right-1 top-1/2 transform -translate-y-1/2' %}
</div>
<!-- Search Results Dropdown -->
<div
id="search-results"
x-show="results.length > 0"
x-transition
x-cloak
class="absolute top-full left-0 right-0 mt-1 bg-background border rounded-md shadow-lg z-50 max-h-96 overflow-y-auto"
>
<!-- Search results will be populated by HTMX -->
</div>
</div>
<!-- Theme Toggle -->
<div x-data="themeToggle()">
<button
@click="toggleTheme()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-10 w-10"
>
<i class="fas fa-sun h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"></i>
<i class="fas fa-moon absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"></i>
<span class="sr-only">Toggle theme</span>
</button>
</div>
<!-- User Menu -->
{% if user.is_authenticated %}
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
<button @click="open = !open" class="relative h-8 w-8 rounded-full">
{% if user.profile.avatar %}
<img
src="{{ user.profile.avatar.url }}"
alt="{{ user.get_full_name|default:user.username }}"
class="h-8 w-8 rounded-full object-cover"
/>
{% else %}
<div class="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium">
{{ user.get_full_name.0|default:user.username.0|upper }}
</div>
{% endif %}
</button>
<!-- User Dropdown -->
<div
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"
x-cloak
class="absolute right-0 mt-2 w-56 bg-background border rounded-md shadow-lg z-50"
>
<div class="flex items-center justify-start gap-2 p-2">
<div class="flex flex-col space-y-1 leading-none">
<p class="font-medium">{{ user.get_full_name|default:user.username }}</p>
<p class="w-[200px] truncate text-sm text-muted-foreground">{{ user.email }}</p>
</div>
</div>
<div class="border-t"></div>
<a href="{% url 'profile' user.username %}" class="flex items-center px-2 py-2 text-sm hover:bg-accent">
<i class="fas fa-user mr-2 h-4 w-4"></i>
Profile
</a>
<a href="{% url 'settings' %}" class="flex items-center px-2 py-2 text-sm hover:bg-accent">
<i class="fas fa-cog mr-2 h-4 w-4"></i>
Settings
</a>
{% if has_moderation_access %}
<a href="{% url 'moderation:dashboard' %}" class="flex items-center px-2 py-2 text-sm hover:bg-accent">
<i class="fas fa-shield-alt mr-2 h-4 w-4"></i>
Moderation
</a>
{% endif %}
<div class="border-t"></div>
<form method="post" action="{% url 'account_logout' %}">
{% csrf_token %}
<button type="submit" class="flex items-center w-full px-2 py-2 text-sm text-red-600 hover:bg-accent">
<i class="fas fa-sign-out-alt mr-2 h-4 w-4"></i>
Log out
</button>
</form>
</div>
</div>
{% else %}
<div class="flex items-center space-x-2">
<button
@click="window.authModal.show('login')"
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 rounded-md px-3"
>
Sign In
</button>
<button
@click="window.authModal.show('register')"
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 rounded-md px-3"
>
Sign Up
</button>
</div>
{% endif %}
</div>
<!-- Mobile Menu -->
<div class="md:hidden flex items-center space-x-2 flex-shrink-0">
<!-- Theme Toggle (Mobile) -->
<div x-data="themeToggle()">
<button
@click="toggleTheme()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-10 w-10"
>
<i class="fas fa-sun h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"></i>
<i class="fas fa-moon absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"></i>
</button>
</div>
<!-- Mobile User Menu -->
{% if user.is_authenticated %}
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
<button @click="open = !open" class="relative h-8 w-8 rounded-full">
{% if user.profile.avatar %}
<img
src="{{ user.profile.avatar.url }}"
alt="{{ user.get_full_name|default:user.username }}"
class="h-8 w-8 rounded-full object-cover"
/>
{% else %}
<div class="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium">
{{ user.get_full_name.0|default:user.username.0|upper }}
</div>
{% endif %}
</button>
<!-- Mobile User Dropdown -->
<div
x-show="open"
x-transition
x-cloak
class="absolute right-0 mt-2 w-56 bg-background border rounded-md shadow-lg z-50"
>
<div class="flex items-center justify-start gap-2 p-2">
<div class="flex flex-col space-y-1 leading-none">
<p class="font-medium">{{ user.get_full_name|default:user.username }}</p>
<p class="w-[200px] truncate text-sm text-muted-foreground">{{ user.email }}</p>
</div>
</div>
<div class="border-t"></div>
<form method="post" action="{% url 'account_logout' %}">
{% csrf_token %}
<button type="submit" class="flex items-center w-full px-2 py-2 text-sm text-red-600 hover:bg-accent">
<i class="fas fa-sign-out-alt mr-2 h-4 w-4"></i>
Log out
</button>
</form>
</div>
</div>
{% else %}
<div class="flex items-center space-x-1">
<div
hx-get="{% url 'account_login' %}"
hx-target="body"
hx-swap="beforeend"
class="cursor-pointer"
>
{% include 'components/ui/button.html' with variant='outline' size='sm' text='Login' %}
</div>
<div
hx-get="{% url 'account_signup' %}"
hx-target="body"
hx-swap="beforeend"
class="cursor-pointer"
>
{% include 'components/ui/button.html' with variant='default' size='sm' text='Join' %}
</div>
</div>
{% endif %}
<!-- Mobile Menu Button -->
<div x-data="{ open: false }">
<button
@click="open = !open"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-10 w-10"
>
<i class="fas fa-bars h-5 w-5"></i>
</button>
<!-- Mobile Menu Overlay -->
<div
x-show="open"
x-transition:enter="transition-opacity ease-linear duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-linear duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
@click="open = false"
>
<!-- Mobile Menu Panel -->
<div
x-show="open"
x-transition:enter="transition ease-in-out duration-300 transform"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition ease-in-out duration-300 transform"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
class="fixed right-0 top-0 h-full w-full sm:w-96 bg-background border-l shadow-lg"
@click.stop
>
<div class="flex flex-col h-full">
<!-- Mobile Menu Header -->
<div class="flex items-center justify-between p-4 border-b">
<div class="flex items-center space-x-2">
<div class="w-6 h-6 bg-purple-600 rounded flex items-center justify-center">
<span class="text-white text-xs font-bold">TW</span>
</div>
<span class="font-bold text-lg">ThrillWiki</span>
</div>
<button
@click="open = false"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-10 w-10"
>
<i class="fas fa-times h-5 w-5"></i>
</button>
</div>
<!-- Mobile Menu Content -->
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<p class="text-sm text-muted-foreground">
Navigate through the ultimate theme park database
</p>
<!-- Navigation Section -->
<div>
<h3 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">
NAVIGATION
</h3>
<div class="space-y-1">
<a href="{% url 'home' %}" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent transition-colors" @click="open = false">
<i class="fas fa-home w-4 h-4"></i>
<span>Home</span>
</a>
<a href="{% url 'search:search' %}" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent transition-colors" @click="open = false">
<i class="fas fa-search w-4 h-4"></i>
<span>Search</span>
</a>
<a href="{% url 'parks:park_list' %}" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent transition-colors" @click="open = false">
<i class="fas fa-map-marker-alt w-4 h-4"></i>
<span>Parks</span>
</a>
<a href="{% url 'rides:global_ride_list' %}" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent transition-colors" @click="open = false">
<i class="fas fa-rocket w-4 h-4"></i>
<span>Rides</span>
</a>
<a href="{% url 'rides:manufacturer_list' %}" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent transition-colors" @click="open = false">
<i class="fas fa-wrench w-4 h-4"></i>
<span>Manufacturers</span>
</a>
<a href="{% url 'parks:operator_list' %}" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent transition-colors" @click="open = false">
<i class="fas fa-building w-4 h-4"></i>
<span>Operators</span>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Mobile Search Bar -->
<div class="md:hidden border-t bg-background">
<div class="px-4 py-3">
<div class="relative">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"></i>
<input
type="search"
placeholder="Search parks, rides..."
class="w-full pl-10 pr-20 h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
hx-get="{% url 'search:search' %}"
hx-trigger="input changed delay:300ms"
hx-target="#mobile-search-results"
hx-include="this"
name="q"
/>
{% include 'components/ui/button.html' with variant='default' size='sm' text='Search' class='absolute right-1 top-1/2 transform -translate-y-1/2' %}
</div>
<div id="mobile-search-results" class="mt-2"></div>
</div>
</div>
</header>

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

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

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

View File

@@ -0,0 +1,63 @@
{% comment %}
Button Component - Django Template Version of shadcn/ui Button
Usage: {% include 'components/ui/button.html' with variant='default' size='default' text='Click me' %}
{% endcomment %}
{% load static %}
{% with variant=variant|default:'default' size=size|default:'default' %}
<button
class="
inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium
ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50
{% if variant == 'default' %}
bg-primary text-primary-foreground hover:bg-primary/90
{% elif variant == 'destructive' %}
bg-destructive text-destructive-foreground hover:bg-destructive/90
{% elif variant == 'outline' %}
border border-input bg-background hover:bg-accent hover:text-accent-foreground
{% elif variant == 'secondary' %}
bg-secondary text-secondary-foreground hover:bg-secondary/80
{% elif variant == 'ghost' %}
hover:bg-accent hover:text-accent-foreground
{% elif variant == 'link' %}
text-primary underline-offset-4 hover:underline
{% endif %}
{% if size == 'default' %}
h-10 px-4 py-2
{% elif size == 'sm' %}
h-9 rounded-md px-3
{% elif size == 'lg' %}
h-11 rounded-md px-8
{% elif size == 'icon' %}
h-10 w-10
{% endif %}
{{ class|default:'' }}
"
{% if type %}type="{{ type }}"{% endif %}
{% if onclick %}onclick="{{ onclick }}"{% endif %}
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if x_data %}x-data="{{ x_data }}"{% endif %}
{% if x_on %}{{ x_on }}{% endif %}
{% if disabled %}disabled{% endif %}
{{ attrs|default:'' }}
>
{% if icon_left %}
<i class="{{ icon_left }} w-4 h-4"></i>
{% endif %}
{% if text %}
{{ text }}
{% else %}
{{ content|default:'' }}
{% endif %}
{% if icon_right %}
<i class="{{ icon_right }} w-4 h-4"></i>
{% endif %}
</button>
{% endwith %}

View File

@@ -0,0 +1,37 @@
{% comment %}
Card Component - Django Template Version of shadcn/ui Card
Usage: {% include 'components/ui/card.html' with title='Card Title' content='Card content' %}
{% endcomment %}
<div class="rounded-lg border bg-card text-card-foreground shadow-sm {{ class|default:'' }}">
{% if title or header_content %}
<div class="flex flex-col space-y-1.5 p-6">
{% if title %}
<h3 class="text-2xl font-semibold leading-none tracking-tight">{{ title }}</h3>
{% endif %}
{% if description %}
<p class="text-sm text-muted-foreground">{{ description }}</p>
{% endif %}
{% if header_content %}
{{ header_content|safe }}
{% endif %}
</div>
{% endif %}
{% if content or body_content %}
<div class="p-6 pt-0">
{% if content %}
{{ content|safe }}
{% endif %}
{% if body_content %}
{{ body_content|safe }}
{% endif %}
</div>
{% endif %}
{% if footer_content %}
<div class="flex items-center p-6 pt-0">
{{ footer_content|safe }}
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,26 @@
{% comment %}
Input Component - Django Template Version of shadcn/ui Input
Usage: {% include 'components/ui/input.html' with type='text' placeholder='Enter text...' name='field_name' %}
{% endcomment %}
<input
type="{{ type|default:'text' }}"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {{ class|default:'' }}"
{% if name %}name="{{ name }}"{% endif %}
{% if id %}id="{{ id }}"{% endif %}
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if value %}value="{{ value }}"{% endif %}
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{% if readonly %}readonly{% endif %}
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %}
{% if x_model %}x-model="{{ x_model }}"{% endif %}
{% if x_on %}{{ x_on }}{% endif %}
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if hx_include %}hx-include="{{ hx_include }}"{% endif %}
{{ attrs|default:'' }}
/>

View File

@@ -0,0 +1,90 @@
{% comment %}
Toast Notification Container Component
Matches React frontend toast functionality with Sonner-like behavior
{% endcomment %}
<!-- Toast Container -->
<div
x-data="toast()"
x-show="$store.toast.toasts.length > 0"
class="fixed top-4 right-4 z-50 space-y-2"
x-cloak
>
<template x-for="toast in $store.toast.toasts" :key="toast.id">
<div
x-show="toast.visible"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="transform opacity-0 translate-x-full"
x-transition:enter-end="transform opacity-100 translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="transform opacity-100 translate-x-0"
x-transition:leave-end="transform opacity-0 translate-x-full"
class="relative max-w-sm w-full bg-background border rounded-lg shadow-lg overflow-hidden"
:class="{
'border-green-200 bg-green-50 dark:bg-green-900/20 dark:border-green-800': toast.type === 'success',
'border-red-200 bg-red-50 dark:bg-red-900/20 dark:border-red-800': toast.type === 'error',
'border-yellow-200 bg-yellow-50 dark:bg-yellow-900/20 dark:border-yellow-800': toast.type === 'warning',
'border-blue-200 bg-blue-50 dark:bg-blue-900/20 dark:border-blue-800': toast.type === 'info'
}"
>
<!-- Progress Bar -->
<div
class="absolute top-0 left-0 h-1 bg-current opacity-30 transition-all duration-100 ease-linear"
:style="`width: ${toast.progress}%`"
:class="{
'text-green-500': toast.type === 'success',
'text-red-500': toast.type === 'error',
'text-yellow-500': toast.type === 'warning',
'text-blue-500': toast.type === 'info'
}"
></div>
<div class="p-4">
<div class="flex items-start">
<!-- Icon -->
<div class="flex-shrink-0 mr-3">
<i
class="w-5 h-5"
:class="{
'fas fa-check-circle text-green-500': toast.type === 'success',
'fas fa-exclamation-circle text-red-500': toast.type === 'error',
'fas fa-exclamation-triangle text-yellow-500': toast.type === 'warning',
'fas fa-info-circle text-blue-500': toast.type === 'info'
}"
></i>
</div>
<!-- Message -->
<div class="flex-1 min-w-0">
<p
class="text-sm font-medium"
:class="{
'text-green-800 dark:text-green-200': toast.type === 'success',
'text-red-800 dark:text-red-200': toast.type === 'error',
'text-yellow-800 dark:text-yellow-200': toast.type === 'warning',
'text-blue-800 dark:text-blue-200': toast.type === 'info'
}"
x-text="toast.message"
></p>
</div>
<!-- Close Button -->
<div class="flex-shrink-0 ml-3">
<button
@click="$store.toast.hide(toast.id)"
class="inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors"
:class="{
'text-green-500 hover:bg-green-100 focus:ring-green-500 dark:hover:bg-green-800': toast.type === 'success',
'text-red-500 hover:bg-red-100 focus:ring-red-500 dark:hover:bg-red-800': toast.type === 'error',
'text-yellow-500 hover:bg-yellow-100 focus:ring-yellow-500 dark:hover:bg-yellow-800': toast.type === 'warning',
'text-blue-500 hover:bg-blue-100 focus:ring-blue-500 dark:hover:bg-blue-800': toast.type === 'info'
}"
>
<i class="fas fa-times w-4 h-4"></i>
</button>
</div>
</div>
</div>
</div>
</template>
</div>

View File

@@ -0,0 +1,310 @@
{# Modern Filter Interface - timestamp: 2025-08-29 #}
{% load static %}
{% load widget_tweaks %}
<div class="space-y-6">
{# Search Section #}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="p-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 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>
Search
</h3>
<form hx-get="{% url 'parks:park_list' %}"
hx-target="#results-container"
hx-trigger="input changed delay:500ms, submit"
hx-indicator="#loading-indicator"
hx-swap="outerHTML"
hx-push-url="true"
class="space-y-4">
<div class="relative">
<input type="text"
name="search"
value="{{ request.GET.search|default:'' }}"
placeholder="Search parks by name, location..."
class="w-full pl-10 pr-4 py-3 border border-gray-300 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 focus:border-blue-500 transition-colors">
<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>
</div>
</form>
</div>
</div>
{# Filters Section #}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="p-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 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="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
</h3>
<form id="filter-form"
hx-get="{% url 'parks:park_list' %}"
hx-target="#results-container"
hx-trigger="change delay:300ms, submit"
hx-indicator="#loading-indicator"
hx-swap="outerHTML"
hx-push-url="true"
class="space-y-6">
{# Preserve search term #}
{% if request.GET.search %}
<input type="hidden" name="search" value="{{ request.GET.search }}">
{% endif %}
{# Status Filter #}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Status
</label>
<select name="status"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
<option value="">All Status</option>
<option value="OPERATING" {% if request.GET.status == 'OPERATING' %}selected{% endif %}>Operating</option>
<option value="CLOSED_TEMP" {% if request.GET.status == 'CLOSED_TEMP' %}selected{% endif %}>Temporarily Closed</option>
<option value="CLOSED_PERM" {% if request.GET.status == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
<option value="UNDER_CONSTRUCTION" {% if request.GET.status == 'UNDER_CONSTRUCTION' %}selected{% endif %}>Under Construction</option>
<option value="DEMOLISHED" {% if request.GET.status == 'DEMOLISHED' %}selected{% endif %}>Demolished</option>
<option value="RELOCATED" {% if request.GET.status == 'RELOCATED' %}selected{% endif %}>Relocated</option>
</select>
</div>
{# Operator Filter #}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Operator
</label>
<select name="operator"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
<option value="">All Operators</option>
<option value="cedar-fair" {% if request.GET.operator == 'cedar-fair' %}selected{% endif %}>Cedar Fair</option>
<option value="six-flags" {% if request.GET.operator == 'six-flags' %}selected{% endif %}>Six Flags</option>
<option value="disney" {% if request.GET.operator == 'disney' %}selected{% endif %}>Disney</option>
<option value="universal" {% if request.GET.operator == 'universal' %}selected{% endif %}>Universal</option>
<option value="busch-gardens" {% if request.GET.operator == 'busch-gardens' %}selected{% endif %}>Busch Gardens</option>
<option value="knott's" {% if request.GET.operator == 'knotts' %}selected{% endif %}>Knott's</option>
<option value="herschend" {% if request.GET.operator == 'herschend' %}selected{% endif %}>Herschend</option>
</select>
</div>
{# Rating Filter #}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Minimum Rating
</label>
<select name="min_rating"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
<option value="">Any Rating</option>
<option value="1" {% if request.GET.min_rating == '1' %}selected{% endif %}>1+ Stars</option>
<option value="2" {% if request.GET.min_rating == '2' %}selected{% endif %}>2+ Stars</option>
<option value="3" {% if request.GET.min_rating == '3' %}selected{% endif %}>3+ Stars</option>
<option value="4" {% if request.GET.min_rating == '4' %}selected{% endif %}>4+ Stars</option>
<option value="5" {% if request.GET.min_rating == '5' %}selected{% endif %}>5+ Stars</option>
<option value="6" {% if request.GET.min_rating == '6' %}selected{% endif %}>6+ Stars</option>
<option value="7" {% if request.GET.min_rating == '7' %}selected{% endif %}>7+ Stars</option>
<option value="8" {% if request.GET.min_rating == '8' %}selected{% endif %}>8+ Stars</option>
<option value="9" {% if request.GET.min_rating == '9' %}selected{% endif %}>9+ Stars</option>
</select>
</div>
{# Ride Count Filter #}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Minimum Rides
</label>
<input type="number"
name="min_rides"
value="{{ request.GET.min_rides|default:'' }}"
min="0"
placeholder="Any number"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
</div>
{# Coaster Count Filter #}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Minimum Coasters
</label>
<input type="number"
name="min_coasters"
value="{{ request.GET.min_coasters|default:'' }}"
min="0"
placeholder="Any number"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
</div>
{# Opening Year Filter #}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Opening Year
</label>
<input type="number"
name="opening_year"
value="{{ request.GET.opening_year|default:'' }}"
min="1800"
max="2030"
placeholder="Any year"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
</div>
{# Size Filter #}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Minimum Size (acres)
</label>
<input type="number"
name="min_size"
value="{{ request.GET.min_size|default:'' }}"
min="0"
step="0.1"
placeholder="Any size"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
</div>
{# Special Features #}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Special Features
</label>
<div class="space-y-3">
<label class="flex items-center">
<input type="checkbox"
name="has_roller_coasters"
value="true"
{% if request.GET.has_roller_coasters %}checked{% endif %}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 focus:ring-2">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Has Roller Coasters</span>
</label>
<label class="flex items-center">
<input type="checkbox"
name="has_water_rides"
value="true"
{% if request.GET.has_water_rides %}checked{% endif %}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 focus:ring-2">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Has Water Rides</span>
</label>
<label class="flex items-center">
<input type="checkbox"
name="family_friendly"
value="true"
{% if request.GET.family_friendly %}checked{% endif %}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 focus:ring-2">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Family Friendly</span>
</label>
</div>
</div>
</form>
</div>
</div>
{# Sort & View Options #}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="p-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 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="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>
</svg>
Sort By
</h3>
<form hx-get="{% url 'parks:park_list' %}"
hx-target="#results-container"
hx-trigger="change"
hx-indicator="#loading-indicator"
hx-swap="outerHTML"
hx-push-url="true">
{# Preserve all current filters #}
{% for key, value in request.GET.items %}
{% if key != 'sort' and key != 'view_mode' %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}
<select name="sort"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors">
<option value="name" {% if request.GET.sort == 'name' or not request.GET.sort %}selected{% endif %}>Name A-Z</option>
<option value="-name" {% if request.GET.sort == '-name' %}selected{% endif %}>Name Z-A</option>
<option value="-average_rating" {% if request.GET.sort == '-average_rating' %}selected{% endif %}>Highest Rated</option>
<option value="average_rating" {% if request.GET.sort == 'average_rating' %}selected{% endif %}>Lowest Rated</option>
<option value="-opening_date" {% if request.GET.sort == '-opening_date' %}selected{% endif %}>Newest</option>
<option value="opening_date" {% if request.GET.sort == 'opening_date' %}selected{% endif %}>Oldest</option>
<option value="-ride_count" {% if request.GET.sort == '-ride_count' %}selected{% endif %}>Most Rides</option>
<option value="-coaster_count" {% if request.GET.sort == '-coaster_count' %}selected{% endif %}>Most Coasters</option>
<option value="-size" {% if request.GET.sort == '-size' %}selected{% endif %}>Largest</option>
<option value="size" {% if request.GET.sort == 'size' %}selected{% endif %}>Smallest</option>
</select>
</form>
</div>
</div>
{# Clear Filters Button #}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="p-4">
<button type="button"
onclick="clearAllFilters()"
class="w-full px-4 py-3 bg-gradient-to-r from-red-500 to-pink-500 hover:from-red-600 hover:to-pink-600 text-white font-medium rounded-lg transition-all duration-200 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 flex items-center justify-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Clear All Filters
</button>
</div>
</div>
</div>
{# Loading Indicator #}
<div id="loading-indicator" class="htmx-indicator fixed top-4 right-4 z-50">
<div class="bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" 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>
Loading...
</div>
</div>
<script>
function clearAllFilters() {
// Navigate to the base URL without any query parameters
window.location.href = "{% url 'parks:park_list' %}";
}
// Enhanced form handling
document.addEventListener('DOMContentLoaded', function() {
// Auto-submit forms on change with debouncing
const forms = document.querySelectorAll('#filter-form, form[hx-trigger*="change"]');
forms.forEach(form => {
let timeout;
form.addEventListener('change', function(e) {
clearTimeout(timeout);
timeout = setTimeout(() => {
htmx.trigger(form, 'submit');
}, 300);
});
});
// Handle search input with longer delay
const searchInput = document.querySelector('input[name="search"]');
if (searchInput) {
let searchTimeout;
searchInput.addEventListener('input', function(e) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
htmx.trigger(e.target.closest('form'), 'submit');
}, 500);
});
}
});
</script>

View File

@@ -0,0 +1,190 @@
{% 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">
{# Desktop Filter Content Container #}
<div id="desktop-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">
{% include "core/search/filters.html" %}
</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">
{# Mobile Filter Toggle and Panel - Moved to top for better UX #}
<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>
{# Mobile Filter Panel - Adjacent to toggle for proper JavaScript functionality #}
<div id="mobile-filter-panel" class="hidden border-t border-gray-200/50 dark:border-gray-700/50">
<div class="p-4">
{% block filter_section %}
<!-- Mobile filter content -->
{% include "core/search/filters.html" %}
{% endblock %}
</div>
</div>
</div>
</div>
{# 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 %}

View File

@@ -0,0 +1,332 @@
{% extends "base/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 %}

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

View File

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

View File

@@ -0,0 +1,300 @@
{% extends "base/base.html" %}
{% load static %}
{% block title %}Search Parks - ThrillWiki{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<!-- Top Filter Bar -->
{% include "core/search/filters.html" %}
<!-- Results Section -->
<div id="search-results">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<!-- Consolidated Search and View Controls -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<!-- Left side: Search bar with results count -->
<div class="flex-1 max-w-md">
<div class="relative">
<input type="text"
name="search"
value="{{ request.GET.search|default:'' }}"
placeholder="Search parks by name, location..."
hx-get="{% url 'parks:park_list' %}"
hx-target="#results-container"
hx-trigger="input changed delay:500ms"
hx-indicator="#loading-indicator"
hx-swap="outerHTML"
hx-push-url="true"
class="w-full pl-10 pr-4 py-2.5 border border-gray-300 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 focus:border-blue-500 transition-colors">
<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>
</div>
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Parks ({{ results.count|default:0 }} found)
</div>
</div>
<!-- Right side: View switching buttons -->
<div class="flex items-center gap-2">
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1" x-data="{ viewMode: '{{ request.GET.view_mode|default:'grid' }}' }">
<button type="button"
@click="viewMode = 'grid'; switchView('grid')"
:class="viewMode === 'grid' ? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors">
<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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
</button>
<button type="button"
@click="viewMode = 'list'; switchView('list')"
:class="viewMode === 'list' ? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors">
<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="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Results Container -->
<div id="results-container" class="divide-y divide-gray-200 dark:divide-gray-700" x-data="{ viewMode: '{{ request.GET.view_mode|default:'grid' }}' }">
<!-- Grid View -->
<div x-show="viewMode === 'grid'" class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for park in results %}
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg overflow-hidden hover:shadow-md transition-shadow">
<!-- Park Image -->
<div class="h-48 bg-gray-200 dark:bg-gray-600 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 dark:text-gray-500">
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
{% endif %}
</div>
<!-- Park Details -->
<div class="p-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
<a href="{{ park.get_absolute_url }}" class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
{{ park.name }}
</a>
</h3>
{% if park.formatted_location %}
<div class="text-sm text-gray-600 dark:text-gray-400 flex items-center mb-3">
<svg class="w-4 h-4 mr-1" 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>
{{ park.formatted_location }}
</div>
{% endif %}
<div class="flex flex-wrap gap-2 mb-3">
{% 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 dark:bg-green-900/30 dark:text-green-300">
<svg class="w-3 h-3 mr-1" 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>
{{ park.average_rating }}/10
</span>
{% endif %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ park.get_status_color }} dark:bg-opacity-30">
{{ 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 dark:bg-purple-900/30 dark:text-purple-300">
{{ park.ride_count }} Ride{{ park.ride_count|pluralize }}
</span>
{% endif %}
</div>
{% if park.description %}
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ park.description|truncatewords:20 }}
</p>
{% endif %}
</div>
</div>
{% empty %}
<div class="col-span-full p-12 text-center text-gray-500 dark:text-gray-400">
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" 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>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No parks found</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Try adjusting your search criteria or filters.</p>
</div>
{% endfor %}
</div>
</div>
<!-- List View -->
<div x-show="viewMode === 'list'" class="divide-y divide-gray-200 dark:divide-gray-700">
{% for park in results %}
<div class="p-6 flex flex-col md:flex-row gap-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<!-- Park Image -->
<div class="md:w-48 h-32 bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden flex-shrink-0">
{% 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 dark:text-gray-500">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
{% endif %}
</div>
<!-- Park Details -->
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
<a href="{{ park.get_absolute_url }}" class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
{{ park.name }}
</a>
</h3>
{% if park.formatted_location %}
<div class="mt-1 text-sm text-gray-600 dark:text-gray-400 flex items-center">
<svg class="w-4 h-4 mr-1" 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>
{{ park.formatted_location }}
</div>
{% endif %}
<div class="mt-3 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 dark:bg-green-900/30 dark:text-green-300">
<svg class="w-3 h-3 mr-1" 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>
{{ park.average_rating }}/10
</span>
{% endif %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ park.get_status_color }} dark:bg-opacity-30">
{{ 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 dark:bg-purple-900/30 dark:text-purple-300">
{{ park.ride_count }} Ride{{ park.ride_count|pluralize }}
</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 dark:bg-yellow-900/30 dark:text-yellow-300">
{{ park.coaster_count }} Coaster{{ park.coaster_count|pluralize }}
</span>
{% endif %}
</div>
{% if park.description %}
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ park.description|truncatewords:30 }}
</p>
{% endif %}
{% if park.opening_date %}
<div class="mt-2 text-xs text-gray-500 dark:text-gray-500">
Opened: {{ park.opening_date|date:"Y" }}
</div>
{% endif %}
</div>
</div>
{% empty %}
<div class="p-12 text-center text-gray-500 dark:text-gray-400">
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" 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>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No parks found</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Try adjusting your search criteria or filters.</p>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{# Include required scripts #}
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://unpkg.com/unpoly@3/unpoly.min.js"></script>
<script>
// View switching functionality
function switchView(mode) {
// Update URL parameter
const url = new URL(window.location);
url.searchParams.set('view_mode', mode);
// Update the URL without reloading
window.history.pushState({}, '', url);
// Store preference in localStorage
localStorage.setItem('parkViewMode', mode);
}
// Initialize view mode from URL or localStorage
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const urlViewMode = urlParams.get('view_mode');
const savedViewMode = localStorage.getItem('parkViewMode');
const defaultViewMode = urlViewMode || savedViewMode || 'grid';
// Set initial view mode
if (!urlViewMode) {
const url = new URL(window.location);
url.searchParams.set('view_mode', defaultViewMode);
window.history.replaceState({}, '', url);
}
});
// Enhanced search functionality
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.querySelector('input[name="search"]');
if (searchInput) {
let searchTimeout;
// Preserve view mode in search requests
searchInput.addEventListener('input', function(e) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
// Get current view mode
const currentViewMode = new URLSearchParams(window.location.search).get('view_mode') || 'grid';
// Add view mode to the HTMX request
const currentUrl = new URL(e.target.getAttribute('hx-get'), window.location.origin);
currentUrl.searchParams.set('view_mode', currentViewMode);
currentUrl.searchParams.set('search', e.target.value);
// Update the hx-get attribute
e.target.setAttribute('hx-get', currentUrl.pathname + currentUrl.search);
// Trigger the HTMX request
htmx.trigger(e.target, 'input');
}, 500);
});
}
});
</script>
{% endblock %}

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

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

View File

@@ -0,0 +1,92 @@
{% extends "base/base.html" %}
{% load static %}
{% block title %}Ride Designers - ThrillWiki{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2">Ride Designers</h1>
<p class="text-muted-foreground">
Discover the creative minds behind the world's most innovative attractions.
{{ total_designers }} designer{{ total_designers|pluralize }} found.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for designer in designers %}
<div class="bg-card rounded-lg border p-6 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-lg font-semibold mb-1">{{ designer.name }}</h3>
{% if designer.founded_date %}
<p class="text-sm text-muted-foreground">Founded {{ designer.founded_date.year }}</p>
{% endif %}
</div>
<div class="text-right">
<span class="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary">
{{ designer.ride_count }} ride{{ designer.ride_count|pluralize }}
</span>
</div>
</div>
{% if designer.description %}
<p class="text-sm text-muted-foreground mb-4 line-clamp-3">
{{ designer.description|truncatewords:20 }}
</p>
{% endif %}
<div class="flex items-center justify-between">
{% if designer.website %}
<a href="{{ designer.website }}" target="_blank" rel="noopener noreferrer"
class="text-sm text-primary hover:underline">
<i class="fas fa-external-link-alt mr-1"></i>
Website
</a>
{% else %}
<span></span>
{% endif %}
<a href="#" class="text-sm text-primary hover:underline">
View Rides →
</a>
</div>
</div>
{% empty %}
<div class="col-span-full text-center py-12">
<i class="fas fa-drafting-compass text-4xl text-muted-foreground mb-4"></i>
<h3 class="text-lg font-semibold mb-2">No designers found</h3>
<p class="text-muted-foreground">There are no designers to display at this time.</p>
</div>
{% endfor %}
</div>
{% if is_paginated %}
<div class="mt-8 flex justify-center">
<nav class="flex items-center space-x-2">
{% if page_obj.has_previous %}
<a href="?page=1" class="px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground">
First
</a>
<a href="?page={{ page_obj.previous_page_number }}" class="px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground">
Previous
</a>
{% endif %}
<span class="px-3 py-2 text-sm font-medium">
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 text-sm font-medium text-muted-foreground hover:text-foreground">
Next
</a>
<a href="?page={{ page_obj.paginator.num_pages }}" class="px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground">
Last
</a>
{% endif %}
</nav>
</div>
{% endif %}
</div>
{% endblock %}

View 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
templates/home.html Normal file
View 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 %}

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

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

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

View File

@@ -0,0 +1,92 @@
{% extends "base/base.html" %}
{% load static %}
{% block title %}Ride Manufacturers - ThrillWiki{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2">Ride Manufacturers</h1>
<p class="text-muted-foreground">
Explore the companies that design and build the world's most thrilling rides.
{{ total_manufacturers }} manufacturer{{ total_manufacturers|pluralize }} found.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for manufacturer in manufacturers %}
<div class="bg-card rounded-lg border p-6 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-lg font-semibold mb-1">{{ manufacturer.name }}</h3>
{% if manufacturer.founded_date %}
<p class="text-sm text-muted-foreground">Founded {{ manufacturer.founded_date.year }}</p>
{% endif %}
</div>
<div class="text-right">
<span class="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary">
{{ manufacturer.ride_count }} ride{{ manufacturer.ride_count|pluralize }}
</span>
</div>
</div>
{% if manufacturer.description %}
<p class="text-sm text-muted-foreground mb-4 line-clamp-3">
{{ manufacturer.description|truncatewords:20 }}
</p>
{% endif %}
<div class="flex items-center justify-between">
{% if manufacturer.website %}
<a href="{{ manufacturer.website }}" target="_blank" rel="noopener noreferrer"
class="text-sm text-primary hover:underline">
<i class="fas fa-external-link-alt mr-1"></i>
Website
</a>
{% else %}
<span></span>
{% endif %}
<a href="#" class="text-sm text-primary hover:underline">
View Rides →
</a>
</div>
</div>
{% empty %}
<div class="col-span-full text-center py-12">
<i class="fas fa-wrench text-4xl text-muted-foreground mb-4"></i>
<h3 class="text-lg font-semibold mb-2">No manufacturers found</h3>
<p class="text-muted-foreground">There are no manufacturers to display at this time.</p>
</div>
{% endfor %}
</div>
{% if is_paginated %}
<div class="mt-8 flex justify-center">
<nav class="flex items-center space-x-2">
{% if page_obj.has_previous %}
<a href="?page=1" class="px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground">
First
</a>
<a href="?page={{ page_obj.previous_page_number }}" class="px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground">
Previous
</a>
{% endif %}
<span class="px-3 py-2 text-sm font-medium">
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 text-sm font-medium text-muted-foreground hover:text-foreground">
Next
</a>
<a href="?page={{ page_obj.paginator.num_pages }}" class="px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground">
Last
</a>
{% endif %}
</nav>
</div>
{% endif %}
</div>
{% endblock %}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,2 @@
{% include "moderation/partials/filters.html" %}
{% include "moderation/partials/submission_list.html" %}

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

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

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

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

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

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

View File

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

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

View File

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

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,92 @@
{% extends "base/base.html" %}
{% load static %}
{% block title %}Park Operators - ThrillWiki{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2">Park Operators</h1>
<p class="text-muted-foreground">
Explore the companies that own and operate theme parks around the world.
{{ total_operators }} operator{{ total_operators|pluralize }} found.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for operator in operators %}
<div class="bg-card rounded-lg border p-6 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-lg font-semibold mb-1">{{ operator.name }}</h3>
{% if operator.founded_date %}
<p class="text-sm text-muted-foreground">Founded {{ operator.founded_date.year }}</p>
{% endif %}
</div>
<div class="text-right">
<span class="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary">
{{ operator.park_count }} park{{ operator.park_count|pluralize }}
</span>
</div>
</div>
{% if operator.description %}
<p class="text-sm text-muted-foreground mb-4 line-clamp-3">
{{ operator.description|truncatewords:20 }}
</p>
{% endif %}
<div class="flex items-center justify-between">
{% if operator.website %}
<a href="{{ operator.website }}" target="_blank" rel="noopener noreferrer"
class="text-sm text-primary hover:underline">
<i class="fas fa-external-link-alt mr-1"></i>
Website
</a>
{% else %}
<span></span>
{% endif %}
<a href="#" class="text-sm text-primary hover:underline">
View Parks →
</a>
</div>
</div>
{% empty %}
<div class="col-span-full text-center py-12">
<i class="fas fa-building text-4xl text-muted-foreground mb-4"></i>
<h3 class="text-lg font-semibold mb-2">No operators found</h3>
<p class="text-muted-foreground">There are no operators to display at this time.</p>
</div>
{% endfor %}
</div>
{% if is_paginated %}
<div class="mt-8 flex justify-center">
<nav class="flex items-center space-x-2">
{% if page_obj.has_previous %}
<a href="?page=1" class="px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground">
First
</a>
<a href="?page={{ page_obj.previous_page_number }}" class="px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground">
Previous
</a>
{% endif %}
<span class="px-3 py-2 text-sm font-medium">
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 text-sm font-medium text-muted-foreground hover:text-foreground">
Next
</a>
<a href="?page={{ page_obj.paginator.num_pages }}" class="px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground">
Last
</a>
{% endif %}
</nav>
</div>
{% endif %}
</div>
{% endblock %}

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

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

View File

@@ -0,0 +1,90 @@
{% extends "base/base.html" %}
{% load static %}
{% block title %}{{ area.name }} - {{ area.park.name }} - ThrillWiki{% endblock %}
{% block content %}
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
<!-- Breadcrumb -->
<nav class="flex mb-4 text-sm" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-3">
<li>
<a href="{% url 'parks:park_detail' area.park.slug %}"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
{{ area.park.name }}
</a>
</li>
<li>
<div class="flex items-center">
<i class="mx-2 text-gray-400 fas fa-chevron-right"></i>
<span class="text-gray-500 dark:text-gray-400">{{ area.name }}</span>
</div>
</li>
</ol>
</nav>
<!-- Area Header -->
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-gray-900 lg:text-3xl dark:text-white">{{ area.name }}</h1>
{% if user.is_authenticated %}
<a href="#" class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-pencil-alt"></i>Edit
</a>
{% endif %}
</div>
{% if area.description %}
<div class="prose dark:prose-invert max-w-none">
{{ area.description|linebreaks }}
</div>
{% endif %}
{% if area.opening_date or area.closing_date %}
<div class="mt-4 space-y-2">
{% if area.opening_date %}
<div class="flex items-center text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-calendar-plus"></i>
<span>Opened: {{ area.opening_date }}</span>
</div>
{% endif %}
{% if area.closing_date %}
<div class="flex items-center text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-calendar-minus"></i>
<span>Closed: {{ area.closing_date }}</span>
</div>
{% endif %}
</div>
{% endif %}
</div>
<!-- Rides in this Area -->
{% if area.rides.exists %}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Rides & Attractions</h2>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{% for ride in area.rides.all %}
<div class="p-4 transition-colors rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
<a href="{% url 'parks:rides:ride_detail' area.park.slug ride.slug %}" class="block">
<h3 class="mb-1 font-semibold text-gray-900 dark:text-white">{{ ride.name }}</h3>
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50">
{{ ride.get_category_display }}
</span>
{% if ride.average_rating %}
<span class="flex items-center text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
<span class="mr-1 text-yellow-500 dark:text-yellow-200"></span>
{{ ride.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</a>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<p class="text-gray-500 dark:text-gray-400">No rides or attractions listed in this area yet.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,285 @@
{% extends "base/base.html" %}
{% load static %}
{% load park_tags %}
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
{% block extra_head %}
{% if park.location.exists %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
{% endif %}
{% endblock %}
{% block content %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('photoUploadModal', () => ({
show: false,
editingPhoto: { caption: '' }
}))
})
</script>
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
<!-- Action Buttons - Above header -->
<div hx-get="{% url 'parks:park_actions' park.slug %}"
hx-trigger="load, auth-changed from:body"
hx-swap="innerHTML">
</div>
<!-- Park Header -->
<div class="p-compact mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
<div class="text-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white lg:text-4xl">{{ park.name }}</h1>
{% if park.formatted_location %}
<div class="flex items-center justify-center mt-2 text-sm text-gray-600 dark:text-gray-400">
<i class="mr-1 fas fa-map-marker-alt"></i>
<p>{{ park.formatted_location }}</p>
</div>
{% endif %}
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
<span class="status-badge text-sm font-medium py-1 px-3 {% if park.status == 'OPERATING' %}status-operating
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif park.status == 'DEMOLISHED' %}status-demolished
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ park.get_status_display }}
</span>
{% if park.average_rating %}
<span class="flex items-center px-3 py-1 text-sm font-medium text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
<span class="mr-1 text-yellow-500 dark:text-yellow-200"></span>
{{ park.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</div>
</div>
</div>
<!-- Horizontal Stats Bar -->
<div class="grid-stats mb-6">
<!-- Operator - Priority Card (First Position) -->
{% if park.operator %}
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats card-stats-priority">
<div class="text-center">
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Operator</dt>
<dd class="mt-1">
<span class="text-sm font-bold text-sky-900 dark:text-sky-400">
{{ park.operator.name }}
</a>
</dd>
</div>
</div>
{% endif %}
<!-- Property Owner (if different from operator) -->
{% if park.property_owner and park.property_owner != park.operator %}
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
<div class="text-center">
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Property Owner</dt>
<dd class="mt-1">
<a href="{% url 'property_owners:property_owner_detail' park.property_owner.slug %}"
class="text-sm font-bold text-sky-900 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-300">
{{ park.property_owner.name }}
</a>
</dd>
</div>
</div>
{% endif %}
<!-- Total Rides -->
<a href="{% url 'parks:rides:ride_list' park.slug %}"
class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats transition-transform hover:scale-[1.02]">
<div class="text-center">
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Total Rides</dt>
<dd class="mt-1 text-2xl font-bold text-sky-900 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-300">{{ park.ride_count|default:"N/A" }}</dd>
</div>
</a>
<!-- Roller Coasters -->
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
<div class="text-center">
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Roller Coasters</dt>
<dd class="mt-1 text-2xl font-bold text-sky-900 dark:text-sky-400">{{ park.coaster_count|default:"N/A" }}</dd>
</div>
</div>
<!-- Status -->
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
<div class="text-center">
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Status</dt>
<dd class="mt-1 text-sm font-bold text-sky-900 dark:text-sky-400">{{ park.get_status_display }}</dd>
</div>
</div>
<!-- Opened Date -->
{% if park.opening_date %}
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
<div class="text-center">
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Opened</dt>
<dd class="mt-1 text-sm font-bold text-sky-900 dark:text-sky-400">{{ park.opening_date }}</dd>
</div>
</div>
{% endif %}
<!-- Website -->
{% if park.website %}
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats">
<div class="text-center">
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Website</dt>
<dd class="mt-1">
<a href="{{ park.website }}"
class="inline-flex items-center text-sm font-bold text-sky-900 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-300"
target="_blank" rel="noopener noreferrer">
Visit
<i class="ml-1 text-xs fas fa-external-link-alt"></i>
</a>
</dd>
</div>
</div>
{% endif %}
</div>
<!-- Rest of the content remains unchanged -->
{% if park.photos.exists %}
<div class="p-optimized mb-8 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
{% include "media/partials/photo_display.html" with photos=park.photos.all content_type="parks.park" object_id=park.id %}
</div>
{% endif %}
<!-- Main Content Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left Column - Description and Rides -->
<div class="lg:col-span-2">
{% if park.description %}
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">About</h2>
<div class="prose dark:prose-invert max-w-none">
{{ park.description|linebreaks }}
</div>
</div>
{% endif %}
<!-- Rides and Attractions -->
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Rides & Attractions</h2>
<a href="{% url 'parks:rides:ride_list' park.slug %}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
View All
</a>
</div>
{% if park.rides.exists %}
<div class="grid gap-4 md:grid-cols-2">
{% for ride in park.rides.all|slice:":6" %}
<div class="p-4 transition-colors rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
<a href="{% url 'parks:rides:ride_detail' park.slug ride.slug %}" class="block">
<h3 class="mb-1 font-semibold text-gray-900 dark:text-white">{{ ride.name }}</h3>
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50">
{{ ride.get_category_display }}
</span>
{% if ride.average_rating %}
<span class="flex items-center text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
<span class="mr-1 text-yellow-500 dark:text-yellow-200"></span>
{{ ride.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</a>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500 dark:text-gray-400">No rides or attractions listed yet.</p>
{% endif %}
</div>
</div>
<!-- Right Column - Map and Additional Info -->
<div class="lg:col-span-1">
<!-- Location Map -->
{% if park.location.exists %}
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Location</h2>
<div id="park-map" class="relative rounded-lg" style="z-index: 0;"></div>
</div>
{% endif %}
<!-- History Panel -->
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">History</h2>
<div class="space-y-4">
{% for record in history %}
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ record.history_date|date:"M d, Y H:i" }}
{% if record.history_user %}
by {{ record.history_user.username }}
{% endif %}
</div>
<div class="mt-2">
{% for field, changes in record.diff_against_previous.items %}
{% if field != "updated_at" %}
<div class="text-sm">
<span class="font-medium">{{ field|title }}:</span>
<span class="text-red-600 dark:text-red-400">{{ changes.old }}</span>
<span class="mx-1"></span>
<span class="text-green-600 dark:text-green-400">{{ changes.new }}</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% empty %}
<p class="text-gray-500">No history available.</p>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<!-- Photo Upload Modal -->
{% if perms.media.add_photo %}
<div x-cloak
x-data="{
show: false,
editingPhoto: null,
init() {
this.editingPhoto = { caption: '' };
}
}"
@show-photo-upload.window="show = true; init()"
x-show="show"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50"
@click.self="show = false">
<div class="w-full max-w-2xl p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Upload Photos</h3>
<button @click="show = false" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<i class="text-xl fas fa-times"></i>
</button>
</div>
{% include "media/partials/photo_upload.html" with content_type="parks.park" object_id=park.id %}
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<!-- Photo Gallery Script -->
<script src="{% static 'js/photo-gallery.js' %}"></script>
<!-- Map Script (if location exists) -->
{% if park.location.exists %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="{% static 'js/park-map.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
{% with location=park.location.first %}
initParkMap({{ location.latitude }}, {{ location.longitude }}, "{{ park.name }}");
{% endwith %}
});
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,321 @@
{% extends "base/base.html" %}
{% load static %}
{% block extra_head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
.photo-preview {
transition: all 0.3s ease;
}
.photo-preview.uploading {
opacity: 0.5;
}
.photo-preview.error {
border-color: red;
}
.photo-preview.success {
border-color: green;
}
</style>
{% endblock %}
{% block content %}
<div class="container px-4 py-8 mx-auto">
<div class="max-w-4xl mx-auto">
<h1 class="mb-6 text-3xl font-bold">
{% if is_edit %}Edit{% else %}Create{% endif %} Park
</h1>
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="parkForm()">
{% csrf_token %}
{# Basic Information #}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold">Basic Information</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="col-span-2">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Name
</label>
{{ form.name }}
</div>
<div class="col-span-2">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
{{ form.description }}
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Owner/Operator
</label>
{{ form.owner }}
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Status
</label>
{{ form.status }}
</div>
</div>
</div>
{# Location #}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold">Location</h2>
{% include "parks/partials/location_widget.html" %}
</div>
{# Photos #}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold">Photos</h2>
{# Existing Photos #}
{% if park.photos.exists %}
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 lg:grid-cols-3">
{% for photo in park.photos.all %}
<div class="relative overflow-hidden rounded-lg aspect-w-16 aspect-h-9">
<img src="{{ photo.image.url }}"
alt="{{ photo.caption|default:park.name }}"
class="object-cover w-full h-full">
<div class="absolute top-0 right-0 p-2">
<button type="button"
class="p-2 text-white bg-red-600 rounded-full hover:bg-red-700"
@click="removePhoto('{{ photo.id }}')">
<i class="fas fa-times"></i>
</button>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{# Photo Upload #}
<div class="space-y-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Add Photos
</label>
<div class="relative">
<input type="file"
multiple
accept="image/*"
class="hidden"
x-ref="fileInput"
@change="handleFileSelect">
<button type="button"
class="w-full px-4 py-2 text-gray-700 border-2 border-dashed rounded-lg dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
@click="$refs.fileInput.click()">
<span x-show="!previews.length">
<i class="mr-2 fas fa-upload"></i>
Click to upload photos
</span>
<span x-show="previews.length">
<i class="mr-2 fas fa-plus"></i>
Add more photos
</span>
</button>
</div>
{# Photo Previews #}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<template x-for="(preview, index) in previews" :key="preview.id">
<div class="relative overflow-hidden transition-all duration-300 rounded-lg aspect-w-16 aspect-h-9 photo-preview"
:class="{
'uploading': preview.uploading,
'error': preview.error,
'success': preview.uploaded
}">
<img :src="preview.url"
class="object-cover w-full h-full">
<div class="absolute top-0 right-0 p-2">
<button type="button"
class="p-2 text-white bg-red-600 rounded-full hover:bg-red-700"
@click="removePreview(index)">
<i class="fas fa-times"></i>
</button>
</div>
<div x-show="preview.uploading"
class="absolute inset-0 flex items-center justify-center bg-black/50">
<div class="w-16 h-16 border-4 border-blue-500 rounded-full animate-spin border-t-transparent"></div>
</div>
<div x-show="preview.error"
class="absolute bottom-0 left-0 right-0 p-2 text-sm text-white bg-red-500">
Upload failed
</div>
</div>
</template>
</div>
</div>
</div>
{# Additional Details #}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold">Additional Details</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Opening Date
</label>
{{ form.opening_date }}
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Closing Date
</label>
{{ form.closing_date }}
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Operating Season
</label>
{{ form.operating_season }}
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Size (acres)
</label>
{{ form.size_acres }}
</div>
<div class="col-span-2">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Website
</label>
{{ form.website }}
</div>
</div>
</div>
{# Submission Details #}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold">Submission Details</h2>
<div class="space-y-4">
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Reason for Changes
</label>
<textarea name="reason" rows="2"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Explain why you're making these changes"></textarea>
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Source
</label>
<input type="text" name="source"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Where did you get this information?">
</div>
</div>
</div>
{# Submit Button #}
<div class="flex justify-end">
<button type="submit"
class="px-6 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800"
:disabled="uploading"
x-text="uploading ? 'Uploading...' : '{% if is_edit %}Save Changes{% else %}Create Park{% endif %}'">
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
function parkForm() {
return {
previews: [],
uploading: false,
handleFileSelect(event) {
const files = event.target.files;
if (!files.length) return;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.type.startsWith('image/')) continue;
const reader = new FileReader();
reader.onload = (e) => {
this.previews.push({
id: Date.now() + i,
file: file,
url: e.target.result,
uploading: false,
uploaded: false,
error: false
});
};
reader.readAsDataURL(file);
}
// Reset file input
event.target.value = '';
},
removePreview(index) {
this.previews.splice(index, 1);
},
async uploadPhotos() {
if (!this.previews.length) return true;
this.uploading = true;
let allUploaded = true;
for (let preview of this.previews) {
if (preview.uploaded || preview.error) continue;
preview.uploading = true;
const formData = new FormData();
formData.append('image', preview.file);
formData.append('app_label', 'parks');
formData.append('model', 'park');
formData.append('object_id', '{{ park.id }}');
try {
const response = await fetch('/photos/upload/', {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
},
body: formData
});
if (!response.ok) throw new Error('Upload failed');
const result = await response.json();
preview.uploading = false;
preview.uploaded = true;
} catch (error) {
console.error('Upload failed:', error);
preview.uploading = false;
preview.error = true;
allUploaded = false;
}
}
this.uploading = false;
return allUploaded;
},
removePhoto(photoId) {
if (confirm('Are you sure you want to remove this photo?')) {
fetch(`/photos/${photoId}/delete/`, {
method: 'DELETE',
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
},
}).then(response => {
if (response.ok) {
window.location.reload();
}
});
}
}
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,93 @@
{% extends "base/base.html" %}
{% load static %}
{% block title %}Parks{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-6">
<!-- Consolidated Search and View Controls Bar -->
<div class="bg-gray-800 rounded-lg p-4 mb-6">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<!-- Search Section -->
<div class="flex-1 max-w-2xl">
<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"></path>
</svg>
</div>
<input
type="text"
name="search"
value="{{ search_query }}"
placeholder="Search parks by name, location, or features..."
class="block w-full pl-10 pr-3 py-2 border border-gray-600 rounded-md leading-5 bg-gray-700 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
hx-get="{% url 'parks:park_list' %}"
hx-trigger="keyup changed delay:300ms"
hx-target="#park-results"
hx-include="[name='view_mode']"
hx-indicator="#search-spinner"
/>
<div id="search-spinner" class="absolute inset-y-0 right-0 pr-3 flex items-center htmx-indicator">
<svg class="animate-spin h-5 w-5 text-gray-400" 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>
<!-- Results Count and View Controls -->
<div class="flex items-center gap-4">
<!-- Results Count -->
<div class="text-gray-300 text-sm whitespace-nowrap">
<span class="font-medium">Parks</span>
{% if total_results %}
<span class="text-gray-400">({{ total_results }} found)</span>
{% endif %}
</div>
<!-- View Mode Toggle -->
<div class="flex bg-gray-700 rounded-lg p-1">
<input type="hidden" name="view_mode" value="{{ view_mode }}" />
<!-- Grid View Button -->
<button
type="button"
class="p-2 rounded-md transition-colors duration-200 {% if view_mode == 'grid' %}bg-blue-600 text-white{% else %}text-gray-400 hover:text-white{% endif %}"
title="Grid View"
hx-get="{% url 'parks:park_list' %}?view_mode=grid"
hx-target="#park-results"
hx-include="[name='search']"
hx-push-url="true"
>
<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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path>
</svg>
</button>
<!-- List View Button -->
<button
type="button"
class="p-2 rounded-md transition-colors duration-200 {% if view_mode == 'list' %}bg-blue-600 text-white{% else %}text-gray-400 hover:text-white{% endif %}"
title="List View"
hx-get="{% url 'parks:park_list' %}?view_mode=list"
hx-target="#park-results"
hx-include="[name='search']"
hx-push-url="true"
>
<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="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Results Container -->
<div id="park-results">
{% include "parks/partials/park_list.html" %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,5 @@
{% if user.is_authenticated %}
<a href="{% url 'parks:park_create' %}" class="btn-primary">
<i class="mr-2 fas fa-plus"></i>Add Park
</a>
{% endif %}

View File

@@ -0,0 +1,356 @@
{% load static %}
<style>
/* Ensure map container and its elements stay below other UI elements */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
z-index: 1 !important;
}
.leaflet-control {
z-index: 2 !important;
}
</style>
<div class="location-widget" id="locationWidget">
{# Search Form #}
<div class="relative mb-4">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Search Location
</label>
<input type="text"
id="locationSearch"
class="relative w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search for a location..."
autocomplete="off"
style="z-index: 10;">
<div id="searchResults"
style="position: absolute; top: 100%; left: 0; right: 0; z-index: 1000;"
class="hidden w-full mt-1 overflow-auto bg-white border rounded-md shadow-lg max-h-60 dark:bg-gray-700 dark:border-gray-600">
</div>
</div>
{# Map Container #}
<div class="relative mb-4" style="z-index: 1;">
<div id="locationMap" class="h-[300px] w-full rounded-lg border border-gray-300 dark:border-gray-600"></div>
</div>
{# Location Form Fields #}
<div class="relative grid grid-cols-1 gap-4 md:grid-cols-2" style="z-index: 10;">
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Street Address
</label>
<input type="text"
name="street_address"
id="streetAddress"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.street_address.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
City
</label>
<input type="text"
name="city"
id="city"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.city.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
State/Region
</label>
<input type="text"
name="state"
id="state"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.state.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Country
</label>
<input type="text"
name="country"
id="country"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.country.value|default:'' }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Postal Code
</label>
<input type="text"
name="postal_code"
id="postalCode"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.postal_code.value|default:'' }}">
</div>
</div>
{# Hidden Coordinate Fields #}
<div class="hidden">
<input type="hidden" name="latitude" id="latitude" value="{{ form.latitude.value|default:'' }}">
<input type="hidden" name="longitude" id="longitude" value="{{ form.longitude.value|default:'' }}">
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
let map = null;
let marker = null;
const searchInput = document.getElementById('locationSearch');
const searchResults = document.getElementById('searchResults');
let searchTimeout;
function normalizeCoordinate(value, maxDigits, decimalPlaces) {
try {
// Convert to string-3 with exact decimal places
const rounded = Number(value).toFixed(decimalPlaces);
// Convert to string-3 without decimal point for digit counting
const strValue = rounded.replace('.', '').replace('-', '');
// Remove trailing zeros
const strippedValue = strValue.replace(/0+$/, '');
// If total digits exceed maxDigits, round further
if (strippedValue.length > maxDigits) {
return Number(Number(value).toFixed(decimalPlaces - 1));
}
// Return the string-3 representation to preserve exact decimal places
return rounded;
} catch (error) {
console.error('Coordinate normalization failed:', error);
return null;
}
}
function validateCoordinates(lat, lng) {
// Normalize coordinates
const normalizedLat = normalizeCoordinate(lat, 9, 6);
const normalizedLng = normalizeCoordinate(lng, 10, 6);
if (normalizedLat === null || normalizedLng === null) {
throw new Error('Invalid coordinate format');
}
const parsedLat = parseFloat(normalizedLat);
const parsedLng = parseFloat(normalizedLng);
if (parsedLat < -90 || parsedLat > 90) {
throw new Error('Latitude must be between -90 and 90 degrees.');
}
if (parsedLng < -180 || parsedLng > 180) {
throw new Error('Longitude must be between -180 and 180 degrees.');
}
return { lat: normalizedLat, lng: normalizedLng };
}
// Initialize map
function initMap() {
map = L.map('locationMap').setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Initialize with existing coordinates if available
const initialLat = document.getElementById('latitude').value;
const initialLng = document.getElementById('longitude').value;
if (initialLat && initialLng) {
try {
const normalized = validateCoordinates(initialLat, initialLng);
addMarker(normalized.lat, normalized.lng);
} catch (error) {
console.error('Invalid initial coordinates:', error);
}
}
// Handle map clicks
map.on('click', async function(e) {
try {
const normalized = validateCoordinates(e.latlng.lat, e.latlng.lng);
const response = await fetch(`/parks/search/reverse-geocode/?lat=${normalized.lat}&lon=${normalized.lng}`);
if (!response.ok) {
throw new Error('Geocoding request failed');
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
updateLocation(normalized.lat, normalized.lng, data);
} catch (error) {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
}
});
}
// Initialize map
initMap();
// Handle location search
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
const query = this.value.trim();
if (!query) {
searchResults.classList.add('hidden');
return;
}
searchTimeout = setTimeout(async function() {
try {
const response = await fetch(`/parks/search/location/?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Search request failed');
}
const data = await response.json();
if (data.results && data.results.length > 0) {
const resultsHtml = data.results.map((result, index) => `
<div class="p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
data-result-index="${index}">
<div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
${[
result.street,
result.city || (result.address && (result.address.city || result.address.town || result.address.village)),
result.state || (result.address && (result.address.state || result.address.region)),
result.country || (result.address && result.address.country),
result.postal_code || (result.address && result.address.postcode)
].filter(Boolean).join(', ')}
</div>
</div>
`).join('');
searchResults.innerHTML = resultsHtml;
searchResults.classList.remove('hidden');
// Store results data
searchResults.dataset.results = JSON.stringify(data.results);
// Add click handlers
searchResults.querySelectorAll('[data-result-index]').forEach(el => {
el.addEventListener('click', function() {
const results = JSON.parse(searchResults.dataset.results);
const result = results[this.dataset.resultIndex];
selectLocation(result);
});
});
} else {
searchResults.innerHTML = '<div class="p-2 text-gray-500 dark:text-gray-400">No results found</div>';
searchResults.classList.remove('hidden');
}
} catch (error) {
console.error('Search failed:', error);
searchResults.innerHTML = '<div class="p-2 text-red-500 dark:text-red-400">Search failed. Please try again.</div>';
searchResults.classList.remove('hidden');
}
}, 300);
});
// Hide search results when clicking outside
document.addEventListener('click', function(e) {
if (!searchResults.contains(e.target) && e.target !== searchInput) {
searchResults.classList.add('hidden');
}
});
function addMarker(lat, lng) {
if (marker) {
marker.remove();
}
marker = L.marker([lat, lng]).addTo(map);
map.setView([lat, lng], 13);
}
function updateLocation(lat, lng, data) {
try {
const normalized = validateCoordinates(lat, lng);
// Update coordinates
document.getElementById('latitude').value = normalized.lat;
document.getElementById('longitude').value = normalized.lng;
// Update marker
addMarker(normalized.lat, normalized.lng);
// Update form fields with English names where available
const address = data.address || {};
document.getElementById('streetAddress').value =
`${address.house_number || ''} ${address.road || address.street || ''}`.trim() || '';
document.getElementById('city').value =
address.city || address.town || address.village || '';
document.getElementById('state').value =
address.state || address.region || '';
document.getElementById('country').value = address.country || '';
document.getElementById('postalCode').value = address.postcode || '';
} catch (error) {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
}
}
function selectLocation(result) {
if (!result) return;
try {
const lat = parseFloat(result.lat);
const lon = parseFloat(result.lon);
if (isNaN(lat) || isNaN(lon)) {
throw new Error('Invalid coordinates in search result');
}
const normalized = validateCoordinates(lat, lon);
// Create a normalized address object
const address = {
name: result.display_name || result.name || '',
address: {
house_number: result.house_number || (result.address && result.address.house_number) || '',
road: result.street || (result.address && (result.address.road || result.address.street)) || '',
city: result.city || (result.address && (result.address.city || result.address.town || result.address.village)) || '',
state: result.state || (result.address && (result.address.state || result.address.region)) || '',
country: result.country || (result.address && result.address.country) || '',
postcode: result.postal_code || (result.address && result.address.postcode) || ''
}
};
updateLocation(normalized.lat, normalized.lng, address);
searchResults.classList.add('hidden');
searchInput.value = address.name;
} catch (error) {
console.error('Location selection failed:', error);
alert(error.message || 'Failed to select location. Please try again.');
}
}
// Add form submit handler
const form = document.querySelector('form');
form.addEventListener('submit', function(e) {
const lat = document.getElementById('latitude').value;
const lng = document.getElementById('longitude').value;
if (lat && lng) {
try {
validateCoordinates(lat, lng);
} catch (error) {
e.preventDefault();
alert(error.message || 'Invalid coordinates. Please check the location.');
}
}
});
});
</script>

View File

@@ -0,0 +1,37 @@
{% load park_tags %}
{% if user.is_authenticated %}
<div class="flex justify-end gap-2 mb-2">
<a href="{% url 'parks:park_update' park.slug %}"
class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-pencil-alt"></i>Edit
</a>
{% include "rides/partials/add_ride_modal.html" %}
{% if perms.media.add_photo %}
<button class="transition-transform btn-secondary hover:scale-105"
@click="$dispatch('show-photo-upload')">
<i class="mr-1 fas fa-camera"></i>Upload Photo
</button>
{% endif %}
<!-- Add/Edit Review Button -->
{% if not park.reviews.exists %}
<a href="{% url 'reviews:add_review' park.slug %}"
class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-star"></i>Add Review
</a>
{% else %}
{% if user|has_reviewed_park:park %}
<a href="{% url 'reviews:edit_review' park.slug %}"
class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-star"></i>Edit Review
</a>
{% else %}
<a href="{% url 'reviews:add_review' park.slug %}"
class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-star"></i>Add Review
</a>
{% endif %}
{% endif %}
</div>
{% endif %}

View File

@@ -0,0 +1,154 @@
{% if view_mode == 'list' %}
<!-- Parks List View -->
<div class="space-y-4">
{% for park in parks %}
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 overflow-hidden">
<div class="flex flex-col md:flex-row">
{% if park.photos.exists %}
<div class="md:w-48 md:flex-shrink-0">
<img src="{{ park.photos.first.image.url }}"
alt="{{ park.name }}"
class="w-full h-48 md:h-full object-cover">
</div>
{% endif %}
<div class="flex-1 p-6">
<div class="flex flex-col md:flex-row md:items-start md:justify-between">
<div class="flex-1">
<h2 class="text-2xl font-bold mb-2">
<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="text-gray-600 dark:text-gray-400 mb-3">
<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 %}
{% endspaceless %}
</p>
{% endif %}
{% if park.operator %}
<p class="text-blue-600 dark:text-blue-400 mb-3">
{{ park.operator.name }}
</p>
{% endif %}
</div>
<div class="flex flex-col items-start md:items-end gap-2 mt-4 md:mt-0">
<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>
</div>
</div>
</div>
</div>
{% empty %}
<div class="py-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No parks found matching your criteria.</p>
</div>
{% endfor %}
</div>
{% else %}
<!-- Parks Grid View -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% 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 h-48">
</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 %}
{% 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-full py-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No parks found matching your criteria.</p>
</div>
{% endfor %}
</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"
hx-get="?page=1&{{ request.GET.urlencode }}"
hx-target="#park-results"
hx-push-url="true">&laquo; First</a>
<a href="?page={{ page_obj.previous_page_number }}&{{ request.GET.urlencode }}"
class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
hx-get="?page={{ page_obj.previous_page_number }}&{{ request.GET.urlencode }}"
hx-target="#park-results"
hx-push-url="true">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"
hx-get="?page={{ page_obj.next_page_number }}&{{ request.GET.urlencode }}"
hx-target="#park-results"
hx-push-url="true">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"
hx-get="?page={{ page_obj.paginator.num_pages }}&{{ request.GET.urlencode }}"
hx-target="#park-results"
hx-push-url="true">Last &raquo;</a>
{% endif %}
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,27 @@
<div class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
{% if parks %}
{% for park in parks %}
<button type="button"
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
onclick="selectPark('{{ park.id }}', '{{ park.name|escapejs }}')">
{{ park.name }}
</button>
{% endfor %}
{% else %}
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
{% if search_term %}
No parks found
{% else %}
Start typing to search...
{% endif %}
</div>
{% endif %}
</div>
<script>
function selectPark(id, name) {
document.getElementById('id_park').value = id;
document.getElementById('id_park_search').value = name;
document.getElementById('park-search-results').innerHTML = '';
}
</script>

View File

@@ -0,0 +1,788 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}Road Trip Planner - ThrillWiki{% endblock %}
{% block extra_head %}
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Leaflet Routing Machine CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.css" />
<style>
.map-container {
height: 70vh;
min-height: 500px;
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.park-selection-card {
@apply bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm hover:shadow-md transition-all cursor-pointer border-2 border-transparent;
}
.park-selection-card:hover {
@apply border-blue-200 dark:border-blue-700;
}
.park-selection-card.selected {
@apply border-blue-500 bg-blue-50 dark:bg-blue-900 dark:bg-opacity-30;
}
.park-card {
@apply bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm;
}
.trip-summary-card {
@apply bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900 dark:to-indigo-900 rounded-lg p-4 shadow-sm;
}
.waypoint-marker {
background: transparent;
border: none;
}
.waypoint-marker-inner {
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 14px;
border: 3px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.waypoint-start .waypoint-marker-inner {
background: #10b981;
}
.waypoint-end .waypoint-marker-inner {
background: #ef4444;
}
.waypoint-stop .waypoint-marker-inner {
background: #3b82f6;
}
.route-line {
color: #3b82f6;
weight: 4;
opacity: 0.7;
}
.dark .route-line {
color: #60a5fa;
}
.trip-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
}
.trip-stat {
@apply text-center;
}
.trip-stat-value {
@apply text-2xl font-bold text-blue-600 dark:text-blue-400;
}
.trip-stat-label {
@apply text-sm text-gray-600 dark:text-gray-400 mt-1;
}
.draggable-item {
cursor: grab;
}
.draggable-item:active {
cursor: grabbing;
}
.drag-over {
@apply border-dashed border-2 border-blue-400 bg-blue-50 dark:bg-blue-900 dark:bg-opacity-30;
}
.park-search-result {
@apply p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700;
}
.park-search-result:last-child {
border-bottom: none;
}
.park-search-result:hover {
@apply bg-gray-50 dark:bg-gray-700;
}
</style>
{% endblock %}
{% block content %}
<div class="container px-4 mx-auto">
<!-- Header -->
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Road Trip Planner</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">
Plan the perfect theme park adventure across multiple destinations
</p>
</div>
<div class="flex gap-3">
<a href="{% url 'maps:universal_map' %}"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="mr-2 fas fa-globe"></i>View Map
</a>
<a href="{% url 'parks:park_list' %}"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="mr-2 fas fa-list"></i>Browse Parks
</a>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left Panel - Trip Planning -->
<div class="lg:col-span-1 space-y-6">
<!-- Park Search -->
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Add Parks to Trip</h3>
<div class="relative">
<input type="text" id="park-search"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search parks by name or location..."
hx-get="{% url 'parks:htmx_search_parks' %}"
hx-trigger="input changed delay:300ms"
hx-target="#park-search-results"
hx-indicator="#search-loading">
<div id="search-loading" class="htmx-indicator absolute right-3 top-3">
<div class="w-4 h-4 border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
</div>
</div>
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 hidden">
<!-- Search results will be populated here -->
</div>
</div>
<!-- Trip Itinerary -->
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
<button id="clear-trip"
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
onclick="tripPlanner.clearTrip()">
<i class="mr-1 fas fa-trash"></i>Clear All
</button>
</div>
<div id="trip-parks" class="space-y-2 min-h-20">
<div id="empty-trip" class="text-center py-8 text-gray-500 dark:text-gray-400">
<i class="fas fa-route text-3xl mb-3"></i>
<p>Add parks to start planning your trip</p>
<p class="text-sm mt-1">Search above or click parks on the map</p>
</div>
</div>
<div class="mt-4 space-y-2">
<button id="optimize-route"
class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick="tripPlanner.optimizeRoute()" disabled>
<i class="mr-2 fas fa-route"></i>Optimize Route
</button>
<button id="calculate-route"
class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick="tripPlanner.calculateRoute()" disabled>
<i class="mr-2 fas fa-map"></i>Calculate Route
</button>
</div>
</div>
<!-- Trip Summary -->
<div id="trip-summary" class="trip-summary-card hidden">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Trip Summary</h3>
<div class="trip-stats">
<div class="trip-stat">
<div class="trip-stat-value" id="total-distance">-</div>
<div class="trip-stat-label">Total Miles</div>
</div>
<div class="trip-stat">
<div class="trip-stat-value" id="total-time">-</div>
<div class="trip-stat-label">Drive Time</div>
</div>
<div class="trip-stat">
<div class="trip-stat-value" id="total-parks">-</div>
<div class="trip-stat-label">Parks</div>
</div>
<div class="trip-stat">
<div class="trip-stat-value" id="total-rides">-</div>
<div class="trip-stat-label">Total Rides</div>
</div>
</div>
<div class="mt-4">
<button id="save-trip"
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
onclick="tripPlanner.saveTrip()">
<i class="mr-2 fas fa-save"></i>Save Trip
</button>
</div>
</div>
</div>
<!-- Right Panel - Map -->
<div class="lg:col-span-2">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Route Map</h3>
<div class="flex gap-2">
<button id="fit-route"
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
onclick="tripPlanner.fitRoute()">
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
</button>
<button id="toggle-parks"
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
onclick="tripPlanner.toggleAllParks()">
<i class="mr-1 fas fa-eye"></i>Show All Parks
</button>
</div>
</div>
<div id="map-container" class="map-container"></div>
<!-- Map Loading Indicator -->
<div id="map-loading" class="htmx-indicator absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
<div class="text-center">
<div class="w-8 h-8 mx-auto mb-4 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
<p class="text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
</div>
</div>
</div>
</div>
</div>
<!-- Saved Trips Section -->
<div class="mt-8">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">My Saved Trips</h3>
<button class="px-3 py-1 text-sm text-blue-600 hover:text-blue-700"
hx-get="{% url 'parks:htmx_saved_trips' %}"
hx-target="#saved-trips"
hx-trigger="click">
<i class="mr-1 fas fa-sync"></i>Refresh
</button>
</div>
<div id="saved-trips"
hx-get="{% url 'parks:htmx_saved_trips' %}"
hx-trigger="load"
hx-indicator="#trips-loading">
<!-- Saved trips will be loaded here -->
</div>
<div id="trips-loading" class="htmx-indicator text-center py-4">
<div class="w-6 h-6 mx-auto border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2">Loading saved trips...</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Leaflet Routing Machine JS -->
<script src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.js"></script>
<!-- Sortable JS for drag & drop -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<script>
// Road Trip Planner class
class TripPlanner {
constructor() {
this.map = null;
this.tripParks = [];
this.allParks = [];
this.parkMarkers = {};
this.routeControl = null;
this.showingAllParks = false;
this.init();
}
init() {
this.initMap();
this.loadAllParks();
this.initDragDrop();
this.bindEvents();
}
initMap() {
// Initialize the map
this.map = L.map('map-container', {
center: [39.8283, -98.5795],
zoom: 4,
zoomControl: false
});
// Add custom zoom control
L.control.zoom({
position: 'bottomright'
}).addTo(this.map);
// Add tile layers with dark mode support
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
});
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors, © CARTO'
});
// Set initial tiles based on theme
if (document.documentElement.classList.contains('dark')) {
darkTiles.addTo(this.map);
} else {
lightTiles.addTo(this.map);
}
// Listen for theme changes
this.observeThemeChanges(lightTiles, darkTiles);
}
observeThemeChanges(lightTiles, darkTiles) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
if (document.documentElement.classList.contains('dark')) {
this.map.removeLayer(lightTiles);
this.map.addLayer(darkTiles);
} else {
this.map.removeLayer(darkTiles);
this.map.addLayer(lightTiles);
}
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
}
async loadAllParks() {
try {
const response = await fetch('{{ map_api_urls.locations }}?types=park&limit=1000');
const data = await response.json();
if (data.status === 'success' && data.data.locations) {
this.allParks = data.data.locations;
}
} catch (error) {
console.error('Failed to load parks:', error);
}
}
initDragDrop() {
// Make trip parks sortable
new Sortable(document.getElementById('trip-parks'), {
animation: 150,
ghostClass: 'drag-over',
onEnd: (evt) => {
this.reorderTripParks(evt.oldIndex, evt.newIndex);
}
});
}
bindEvents() {
// Handle park search results
document.addEventListener('htmx:afterRequest', (event) => {
if (event.target.id === 'park-search-results') {
this.handleSearchResults();
}
});
}
handleSearchResults() {
const results = document.getElementById('park-search-results');
if (results.children.length > 0) {
results.classList.remove('hidden');
} else {
results.classList.add('hidden');
}
}
addParkToTrip(parkData) {
// Check if park already in trip
if (this.tripParks.find(p => p.id === parkData.id)) {
return;
}
this.tripParks.push(parkData);
this.updateTripDisplay();
this.updateTripMarkers();
this.updateButtons();
// Hide search results
document.getElementById('park-search-results').classList.add('hidden');
document.getElementById('park-search').value = '';
}
removeParkFromTrip(parkId) {
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
this.updateTripDisplay();
this.updateTripMarkers();
this.updateButtons();
if (this.routeControl) {
this.map.removeControl(this.routeControl);
this.routeControl = null;
}
}
updateTripDisplay() {
const container = document.getElementById('trip-parks');
const emptyState = document.getElementById('empty-trip');
if (this.tripParks.length === 0) {
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
// Clear existing parks (except empty state)
Array.from(container.children).forEach(child => {
if (child.id !== 'empty-trip') {
child.remove();
}
});
// Add trip parks
this.tripParks.forEach((park, index) => {
const parkElement = this.createTripParkElement(park, index);
container.appendChild(parkElement);
});
}
createTripParkElement(park, index) {
const div = document.createElement('div');
div.className = 'park-card draggable-item';
div.innerHTML = `
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<div class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold">
${index + 1}
</div>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 dark:text-white truncate">
${park.name}
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 truncate">
${park.formatted_location || 'Location not specified'}
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
class="text-red-500 hover:text-red-700 p-1">
<i class="fas fa-times"></i>
</button>
<i class="fas fa-grip-vertical text-gray-400 cursor-grab"></i>
</div>
</div>
`;
return div;
}
updateTripMarkers() {
// Clear existing trip markers
Object.values(this.parkMarkers).forEach(marker => {
this.map.removeLayer(marker);
});
this.parkMarkers = {};
// Add markers for trip parks
this.tripParks.forEach((park, index) => {
const marker = this.createTripMarker(park, index);
this.parkMarkers[park.id] = marker;
marker.addTo(this.map);
});
// Fit map to show all trip parks
if (this.tripParks.length > 0) {
this.fitRoute();
}
}
createTripMarker(park, index) {
let markerClass = 'waypoint-stop';
if (index === 0) markerClass = 'waypoint-start';
if (index === this.tripParks.length - 1 && this.tripParks.length > 1) markerClass = 'waypoint-end';
const icon = L.divIcon({
className: `waypoint-marker ${markerClass}`,
html: `<div class="waypoint-marker-inner">${index + 1}</div>`,
iconSize: [30, 30],
iconAnchor: [15, 15]
});
const marker = L.marker([park.latitude, park.longitude], { icon });
const popupContent = `
<div class="text-center">
<h3 class="font-semibold mb-2">${park.name}</h3>
<div class="text-sm text-gray-600 mb-2">Stop ${index + 1}</div>
${park.ride_count ? `<div class="text-sm text-gray-600 mb-2">${park.ride_count} rides</div>` : ''}
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
class="px-3 py-1 text-sm text-red-600 border border-red-600 rounded hover:bg-red-50">
Remove from Trip
</button>
</div>
`;
marker.bindPopup(popupContent);
return marker;
}
reorderTripParks(oldIndex, newIndex) {
const park = this.tripParks.splice(oldIndex, 1)[0];
this.tripParks.splice(newIndex, 0, park);
this.updateTripDisplay();
this.updateTripMarkers();
// Clear route to force recalculation
if (this.routeControl) {
this.map.removeControl(this.routeControl);
this.routeControl = null;
}
}
async optimizeRoute() {
if (this.tripParks.length < 2) return;
try {
const parkIds = this.tripParks.map(p => p.id);
const response = await fetch('{% url "parks:htmx_optimize_route" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({ park_ids: parkIds })
});
const data = await response.json();
if (data.status === 'success' && data.optimized_order) {
// Reorder parks based on optimization
const optimizedParks = data.optimized_order.map(id =>
this.tripParks.find(p => p.id === id)
).filter(Boolean);
this.tripParks = optimizedParks;
this.updateTripDisplay();
this.updateTripMarkers();
}
} catch (error) {
console.error('Route optimization failed:', error);
}
}
async calculateRoute() {
if (this.tripParks.length < 2) return;
// Remove existing route
if (this.routeControl) {
this.map.removeControl(this.routeControl);
}
const waypoints = this.tripParks.map(park =>
L.latLng(park.latitude, park.longitude)
);
this.routeControl = L.Routing.control({
waypoints: waypoints,
routeWhileDragging: false,
addWaypoints: false,
createMarker: () => null, // Don't create default markers
lineOptions: {
styles: [{ color: '#3b82f6', weight: 4, opacity: 0.7 }]
}
}).addTo(this.map);
this.routeControl.on('routesfound', (e) => {
const route = e.routes[0];
this.updateTripSummary(route);
});
}
updateTripSummary(route) {
if (!route) return;
const totalDistance = (route.summary.totalDistance / 1609.34).toFixed(1); // Convert to miles
const totalTime = this.formatDuration(route.summary.totalTime);
const totalRides = this.tripParks.reduce((sum, park) => sum + (park.ride_count || 0), 0);
document.getElementById('total-distance').textContent = totalDistance;
document.getElementById('total-time').textContent = totalTime;
document.getElementById('total-parks').textContent = this.tripParks.length;
document.getElementById('total-rides').textContent = totalRides;
document.getElementById('trip-summary').classList.remove('hidden');
}
formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
fitRoute() {
if (this.tripParks.length === 0) return;
const group = new L.featureGroup(Object.values(this.parkMarkers));
this.map.fitBounds(group.getBounds().pad(0.1));
}
toggleAllParks() {
// Implementation for showing/hiding all parks on the map
const button = document.getElementById('toggle-parks');
const icon = button.querySelector('i');
if (this.showingAllParks) {
// Hide all parks
this.showingAllParks = false;
icon.className = 'mr-1 fas fa-eye';
button.innerHTML = icon.outerHTML + 'Show All Parks';
} else {
// Show all parks
this.showingAllParks = true;
icon.className = 'mr-1 fas fa-eye-slash';
button.innerHTML = icon.outerHTML + 'Hide All Parks';
this.displayAllParks();
}
}
displayAllParks() {
// Add markers for all parks (implementation depends on requirements)
this.allParks.forEach(park => {
if (!this.parkMarkers[park.id]) {
const marker = L.marker([park.latitude, park.longitude], {
icon: L.divIcon({
className: 'location-marker location-marker-park',
html: '<div class="location-marker-inner">🎢</div>',
iconSize: [20, 20],
iconAnchor: [10, 10]
})
});
marker.bindPopup(`
<div class="text-center">
<h3 class="font-semibold mb-2">${park.name}</h3>
<button onclick="tripPlanner.addParkToTrip(${JSON.stringify(park).replace(/"/g, '&quot;')})"
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
Add to Trip
</button>
</div>
`);
marker.addTo(this.map);
this.parkMarkers[`all_${park.id}`] = marker;
}
});
}
updateButtons() {
const optimizeBtn = document.getElementById('optimize-route');
const calculateBtn = document.getElementById('calculate-route');
const hasEnoughParks = this.tripParks.length >= 2;
optimizeBtn.disabled = !hasEnoughParks;
calculateBtn.disabled = !hasEnoughParks;
}
clearTrip() {
this.tripParks = [];
this.updateTripDisplay();
this.updateTripMarkers();
this.updateButtons();
if (this.routeControl) {
this.map.removeControl(this.routeControl);
this.routeControl = null;
}
document.getElementById('trip-summary').classList.add('hidden');
}
async saveTrip() {
if (this.tripParks.length === 0) return;
const tripName = prompt('Enter a name for this trip:');
if (!tripName) return;
try {
const response = await fetch('{% url "parks:htmx_save_trip" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({
name: tripName,
park_ids: this.tripParks.map(p => p.id)
})
});
const data = await response.json();
if (data.status === 'success') {
alert('Trip saved successfully!');
// Refresh saved trips
htmx.trigger('#saved-trips', 'refresh');
} else {
alert('Failed to save trip: ' + (data.message || 'Unknown error'));
}
} catch (error) {
console.error('Save trip failed:', error);
alert('Failed to save trip');
}
}
}
// Global function for adding parks from search results
window.addParkToTrip = function(parkData) {
window.tripPlanner.addParkToTrip(parkData);
};
// Initialize trip planner when page loads
document.addEventListener('DOMContentLoaded', function() {
window.tripPlanner = new TripPlanner();
// Hide search results when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('#park-search') && !e.target.closest('#park-search-results')) {
document.getElementById('park-search-results').classList.add('hidden');
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,139 @@
{% extends "base/base.html" %}
{% load static %}
{% load ride_tags %}
{% block title %}
{% if park %}
{{ category }} at {{ park.name }} - ThrillWiki
{% else %}
{{ category }} - ThrillWiki
{% endif %}
{% endblock %}
{% block content %}
<div class="container px-4 py-8 mx-auto">
<div class="mb-8">
{% if park %}
<h1 class="mb-2 text-3xl font-bold">{{ category }} at {{ park.name }}</h1>
<a href="{% url 'parks:park_detail' park.slug %}" class="text-blue-600 hover:text-blue-800">← Back to {{ park.name }}</a>
{% else %}
<h1 class="mb-2 text-3xl font-bold">{{ category }}</h1>
<a href="{% url 'rides:global_ride_list' %}" class="text-blue-600 hover:text-blue-800">← Back to All Rides</a>
{% endif %}
</div>
{% if rides %}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for ride in rides %}
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<div class="aspect-w-16 aspect-h-9">
{% if ride.photos.exists %}
<img src="{{ ride.photos.first.image.url }}"
alt="{{ ride.name }}"
class="object-cover w-full">
{% else %}
<img src="{% get_ride_placeholder_image ride.category %}"
alt="{{ ride.name }}"
class="object-cover w-full">
{% endif %}
</div>
<div class="p-4">
<h2 class="mb-2 text-xl font-bold">
{% if park %}
<a href="{% url 'parks:rides:ride_detail' park.slug ride.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ ride.name }}
</a>
{% else %}
<a href="{% url 'rides:ride_detail' ride.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ ride.name }}
</a>
{% endif %}
</h2>
{% if not park %}
<p class="mb-3 text-gray-600 dark:text-gray-400">
at <a href="{% url 'parks:park_detail' ride.park.slug %}"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
{{ ride.park.name }}
</a>
</p>
{% endif %}
{% if ride.ride_model %}
<p class="mb-2 text-gray-600 dark:text-gray-400">
Model: {{ ride.ride_model.name }}
{% if ride.ride_model.manufacturer %}
by {{ ride.ride_model.manufacturer.name }}
{% endif %}
</p>
{% endif %}
<div class="flex flex-wrap gap-2">
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif ride.status == 'DEMOLISHED' %}status-demolished
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ ride.get_status_display }}
</span>
{% if ride.average_rating %}
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
{{ ride.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</div>
{% if ride.coaster_stats %}
<div class="grid grid-cols-2 gap-2 mt-4">
{% if ride.coaster_stats.height_ft %}
<div class="text-sm text-gray-600 dark:text-gray-400">
Height: {{ ride.coaster_stats.height_ft }}ft
</div>
{% endif %}
{% if ride.coaster_stats.speed_mph %}
<div class="text-sm text-gray-600 dark:text-gray-400">
Speed: {{ ride.coaster_stats.speed_mph }}mph
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="p-8 text-center rounded-lg bg-gray-50 dark:bg-gray-800">
{% if park %}
<p class="text-gray-600 dark:text-gray-400">No {{ category|lower }} found at this park.</p>
{% else %}
<p class="text-gray-600 dark:text-gray-400">No {{ category|lower }} found.</p>
{% endif %}
</div>
{% endif %}
<!-- Pagination -->
{% if is_paginated %}
<div class="flex justify-center mt-6">
<div class="inline-flex rounded-md shadow-xs">
{% if page_obj.has_previous %}
<a href="?page=1{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">&laquo; First</a>
<a href="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
{% endif %}
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
<a href="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last &raquo;</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
<div class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
{% if designers %}
{% for designer in designers %}
<button type="button"
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
onclick="selectDesigner('{{ designer.id }}', '{{ designer.name|escapejs }}')">
{{ designer.name }}
</button>
{% endfor %}
{% else %}
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
{% if search_term %}
No matches found. You can still submit this name.
{% else %}
Start typing to search...
{% endif %}
</div>
{% endif %}
</div>
<script>
function selectDesigner(id, name) {
document.getElementById('id_designer').value = id;
document.getElementById('id_designer_search').value = name;
document.getElementById('designer-search-results').innerHTML = '';
}
</script>

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More