mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-24 10:11:08 -05:00
Add secret management guide, client-side performance monitoring, and search accessibility enhancements
- Introduced a comprehensive Secret Management Guide detailing best practices, secret classification, development setup, production management, rotation procedures, and emergency protocols. - Implemented a client-side performance monitoring script to track various metrics including page load performance, paint metrics, layout shifts, and memory usage. - Enhanced search accessibility with keyboard navigation support for search results, ensuring compliance with WCAG standards and improving user experience.
This commit is contained in:
@@ -2,8 +2,10 @@
|
||||
{% load account socialaccount %}
|
||||
{% load turnstile_tags %}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
<form
|
||||
class="space-y-6"
|
||||
role="form"
|
||||
aria-label="{% trans 'Sign in to your account' %}"
|
||||
hx-post="{% url 'account_login' %}"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML"
|
||||
@@ -11,44 +13,18 @@
|
||||
>
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-error">
|
||||
<div class="alert alert-error" role="alert">
|
||||
<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>
|
||||
<fieldset class="space-y-6">
|
||||
<legend class="sr-only">{% trans "Login credentials" %}</legend>
|
||||
|
||||
<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>
|
||||
{% include 'forms/partials/form_field.html' with field=form.login label=_("Username or Email") %}
|
||||
|
||||
{% include 'forms/partials/form_field.html' with field=form.password label=_("Password") %}
|
||||
</fieldset>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
@@ -87,7 +63,7 @@
|
||||
|
||||
<div>
|
||||
<button type="submit" class="w-full btn-primary">
|
||||
<i class="mr-2 fas fa-sign-in-alt"></i>
|
||||
<i class="mr-2 fas fa-sign-in-alt" aria-hidden="true"></i>
|
||||
{% trans "Sign In" %}
|
||||
</button>
|
||||
</div>
|
||||
@@ -96,6 +72,6 @@
|
||||
<!-- 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 class="w-8 h-8 border-4 rounded-full border-primary border-t-transparent animate-spin" role="status" aria-label="Loading"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,14 +26,16 @@
|
||||
{% if provider.id == 'google' %}
|
||||
<img
|
||||
src="{% static 'images/google-icon.svg' %}"
|
||||
alt="Google"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
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"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span>Continue with Discord</span>
|
||||
@@ -47,101 +49,51 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="space-y-6" method="POST" action="{% url 'account_signup' %}">
|
||||
<form class="space-y-6" method="POST" action="{% url 'account_signup' %}" role="form" aria-label="{% trans 'Create a new account' %}">
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-error">
|
||||
<div class="alert alert-error" role="alert">
|
||||
<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>
|
||||
<fieldset class="space-y-6">
|
||||
<legend class="sr-only">{% trans "Account information" %}</legend>
|
||||
|
||||
<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>
|
||||
{% include 'forms/partials/form_field.html' with field=form.username label=_("Username") %}
|
||||
|
||||
<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>
|
||||
{% include 'forms/partials/form_field.html' with field=form.email label=_("Email") %}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-6">
|
||||
<legend class="sr-only">{% trans "Password" %}</legend>
|
||||
|
||||
<div>
|
||||
{% include 'forms/partials/form_field.html' with field=form.password1 label=_("Password") show_help=False %}
|
||||
<div class="mt-3 password-requirements" aria-live="polite">
|
||||
<ul id="passwordRequirements" role="list" aria-label="{% trans 'Password requirements' %}">
|
||||
<li class="invalid" id="req-length">
|
||||
<i class="text-xs fas fa-circle" aria-hidden="true"></i>
|
||||
<span>Must be at least 8 characters long</span>
|
||||
</li>
|
||||
<li class="invalid" id="req-similar">
|
||||
<i class="text-xs fas fa-circle" aria-hidden="true"></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" aria-hidden="true"></i>
|
||||
<span>Can't be a commonly used password</span>
|
||||
</li>
|
||||
<li class="invalid" id="req-numeric">
|
||||
<i class="text-xs fas fa-circle" aria-hidden="true"></i>
|
||||
<span>Can't be entirely numeric</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</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>
|
||||
{% include 'forms/partials/form_field.html' with field=form.password2 label=_("Confirm Password") %}
|
||||
</fieldset>
|
||||
|
||||
{% turnstile_widget %}
|
||||
{% if redirect_field_value %}
|
||||
@@ -154,7 +106,7 @@
|
||||
|
||||
<div>
|
||||
<button type="submit" class="w-full btn-primary">
|
||||
<i class="mr-2 fas fa-user-plus"></i>
|
||||
<i class="mr-2 fas fa-user-plus" aria-hidden="true"></i>
|
||||
{% trans "Create Account" %}
|
||||
</button>
|
||||
</div>
|
||||
@@ -239,5 +191,21 @@
|
||||
password2Input.classList.remove("border-green-500", "border-red-500");
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listeners after DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const password1Input = document.getElementById('id_password1');
|
||||
const password2Input = document.getElementById('id_password2');
|
||||
|
||||
if (password1Input) {
|
||||
password1Input.addEventListener('input', function() {
|
||||
validatePassword(this.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (password2Input) {
|
||||
password2Input.addEventListener('input', validatePasswordMatch);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -69,9 +69,16 @@
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/x-icon" href="{% static 'favicon.ico' %}">
|
||||
|
||||
<!-- Fonts - Preconnect for performance -->
|
||||
<!-- Resource Hints for Performance -->
|
||||
<!-- Preconnect to external domains for faster resource loading -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="preconnect" href="https://imagedelivery.net">
|
||||
<link rel="dns-prefetch" href="https://imagedelivery.net">
|
||||
<link rel="dns-prefetch" href="https://unpkg.com">
|
||||
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
|
||||
|
||||
<!-- Fonts - Preload critical font -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Playfair+Display:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Font Awesome Icons -->
|
||||
@@ -91,9 +98,15 @@
|
||||
</script>
|
||||
|
||||
<!-- Design System CSS - Load in correct order -->
|
||||
{% if debug %}
|
||||
<link href="{% static 'css/design-tokens.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'css/components.css' %}" rel="stylesheet">
|
||||
{% else %}
|
||||
<link href="{% static 'css/design-tokens.min.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'css/tailwind.min.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'css/components.min.css' %}" rel="stylesheet">
|
||||
{% endif %}
|
||||
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
@@ -106,13 +119,25 @@
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Alpine.js Stores (must load before alpine:init) -->
|
||||
{% if debug %}
|
||||
<script src="{% static 'js/stores/index.js' %}"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'js/stores/index.min.js' %}"></script>
|
||||
{% endif %}
|
||||
|
||||
<!-- Alpine.js Components -->
|
||||
{% if debug %}
|
||||
<script src="{% static 'js/alpine-components.js' %}"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'js/alpine-components.min.js' %}"></script>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location Autocomplete -->
|
||||
{% if debug %}
|
||||
<script src="{% static 'js/location-autocomplete.js' %}"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'js/location-autocomplete.min.js' %}"></script>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
/* Hide elements until Alpine.js is ready */
|
||||
@@ -220,9 +245,19 @@
|
||||
{% include 'components/ui/toast-container.html' %}
|
||||
|
||||
<!-- Core JavaScript -->
|
||||
{% if debug %}
|
||||
<script src="{% static 'js/main.js' %}"></script>
|
||||
<script src="{% static 'js/alerts.js' %}"></script>
|
||||
<script src="{% static 'js/fsm-transitions.js' %}"></script>
|
||||
<script src="{% static 'js/search-accessibility.js' %}"></script>
|
||||
<script src="{% static 'js/optimization.js' %}"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'js/main.min.js' %}"></script>
|
||||
<script src="{% static 'js/alerts.min.js' %}"></script>
|
||||
<script src="{% static 'js/fsm-transitions.min.js' %}"></script>
|
||||
<script src="{% static 'js/search-accessibility.min.js' %}"></script>
|
||||
<script src="{% static 'js/optimization.min.js' %}"></script>
|
||||
{% endif %}
|
||||
|
||||
<!-- HTMX Configuration and Error Handling -->
|
||||
<script>
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
{% comment %}
|
||||
Enhanced Header Component - Matches React Frontend Design
|
||||
Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
|
||||
ACCESSIBILITY PATTERNS:
|
||||
- All dropdown menus use aria-expanded and aria-haspopup for screen readers
|
||||
- Menu items use role="menu" and role="menuitem" for proper ARIA semantics
|
||||
- Search inputs have associated labels (sr-only) for screen reader accessibility
|
||||
- Theme toggle uses aria-pressed state to announce current mode
|
||||
- Mobile menu uses role="dialog" with aria-modal for modal semantics
|
||||
- Focus management: Tab, Enter, Escape keys supported on all interactive elements
|
||||
- Keyboard navigation: Arrow keys for menu items (handled by Alpine.js)
|
||||
{% endcomment %}
|
||||
|
||||
{% load static %}
|
||||
@@ -27,17 +36,21 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
@mouseleave="open = false"
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
<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"
|
||||
@keydown.escape="open = false"
|
||||
aria-label="Browse menu"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="open.toString()"
|
||||
>
|
||||
<i class="fas fa-compass w-4 h-4"></i>
|
||||
<i class="fas fa-compass w-4 h-4" aria-hidden="true"></i>
|
||||
Browse
|
||||
<i class="fas fa-chevron-down w-4 h-4"></i>
|
||||
<i class="fas fa-chevron-down w-4 h-4" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<!-- Browse Dropdown -->
|
||||
<div
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
@@ -46,83 +59,91 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
x-cloak
|
||||
role="menu"
|
||||
aria-label="Browse navigation"
|
||||
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' %}"
|
||||
<a
|
||||
href="{% url 'parks:park_list' %}"
|
||||
role="menuitem"
|
||||
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>
|
||||
<i class="fas fa-map-marker-alt w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground" aria-hidden="true"></i>
|
||||
<div>
|
||||
<h3 class="font-semibold text-sm mb-1">Parks</h3>
|
||||
<p class="text-xs text-muted-foreground">Explore theme parks worldwide</p>
|
||||
<span class="font-semibold text-sm mb-1 block">Parks</span>
|
||||
<span class="text-xs text-muted-foreground">Explore theme parks worldwide</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="{% url 'rides:manufacturer_list' %}"
|
||||
<a
|
||||
href="{% url 'rides:manufacturer_list' %}"
|
||||
role="menuitem"
|
||||
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>
|
||||
<i class="fas fa-wrench w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground" aria-hidden="true"></i>
|
||||
<div>
|
||||
<h3 class="font-semibold text-sm mb-1">Manufacturers</h3>
|
||||
<p class="text-xs text-muted-foreground">Ride and attraction manufacturers</p>
|
||||
<span class="font-semibold text-sm mb-1 block">Manufacturers</span>
|
||||
<span class="text-xs text-muted-foreground">Ride and attraction manufacturers</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="{% url 'parks:operator_list' %}"
|
||||
<a
|
||||
href="{% url 'parks:operator_list' %}"
|
||||
role="menuitem"
|
||||
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>
|
||||
<i class="fas fa-users w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground" aria-hidden="true"></i>
|
||||
<div>
|
||||
<h3 class="font-semibold text-sm mb-1">Operators</h3>
|
||||
<p class="text-xs text-muted-foreground">Theme park operating companies</p>
|
||||
<span class="font-semibold text-sm mb-1 block">Operators</span>
|
||||
<span class="text-xs text-muted-foreground">Theme park operating companies</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-4">
|
||||
<a
|
||||
href="{% url 'rides:global_ride_list' %}"
|
||||
<a
|
||||
href="{% url 'rides:global_ride_list' %}"
|
||||
role="menuitem"
|
||||
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>
|
||||
<i class="fas fa-rocket w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground" aria-hidden="true"></i>
|
||||
<div>
|
||||
<h3 class="font-semibold text-sm mb-1">Rides</h3>
|
||||
<p class="text-xs text-muted-foreground">Discover rides and attractions</p>
|
||||
<span class="font-semibold text-sm mb-1 block">Rides</span>
|
||||
<span class="text-xs text-muted-foreground">Discover rides and attractions</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="{% url 'rides:designer_list' %}"
|
||||
<a
|
||||
href="{% url 'rides:designer_list' %}"
|
||||
role="menuitem"
|
||||
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>
|
||||
<i class="fas fa-drafting-compass w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground" aria-hidden="true"></i>
|
||||
<div>
|
||||
<h3 class="font-semibold text-sm mb-1">Designers</h3>
|
||||
<p class="text-xs text-muted-foreground">Ride designers and architects</p>
|
||||
<span class="font-semibold text-sm mb-1 block">Designers</span>
|
||||
<span class="text-xs text-muted-foreground">Ride designers and architects</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
<a
|
||||
href="#"
|
||||
role="menuitem"
|
||||
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>
|
||||
<i class="fas fa-trophy w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground" aria-hidden="true"></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>
|
||||
<span class="font-semibold text-sm mb-1 block">Top Lists</span>
|
||||
<span class="text-xs text-muted-foreground">Community rankings and favorites</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -135,10 +156,12 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
<!-- Desktop Right Side -->
|
||||
<div class="hidden md:flex items-center space-x-4">
|
||||
<!-- Enhanced Search (HTMX-driven) -->
|
||||
<div class="relative">
|
||||
<div class="relative" role="search">
|
||||
<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>
|
||||
<label for="desktop-search" class="sr-only">Search parks and rides</label>
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" aria-hidden="true"></i>
|
||||
<input
|
||||
id="desktop-search"
|
||||
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"
|
||||
@@ -148,51 +171,63 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
hx-indicator=".htmx-loading-indicator"
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
aria-describedby="search-results-status"
|
||||
aria-controls="search-results"
|
||||
/>
|
||||
{% 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: always present and controlled by HTMX swaps -->
|
||||
<div
|
||||
<div
|
||||
id="search-results"
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
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"
|
||||
aria-live="polite"
|
||||
>
|
||||
<!-- Search results will be populated by HTMX -->
|
||||
</div>
|
||||
<div id="search-results-status" class="sr-only" aria-live="polite" aria-atomic="true"></div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<div x-data="themeToggle()">
|
||||
<button
|
||||
<button
|
||||
@click="toggleTheme()"
|
||||
aria-label="Toggle theme"
|
||||
:aria-pressed="$store.theme ? $store.theme.isDark.toString() : '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-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>
|
||||
<i class="fas fa-sun h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" aria-hidden="true"></i>
|
||||
<i class="fas fa-moon absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" aria-hidden="true"></i>
|
||||
</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">
|
||||
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @keydown.escape="open = false">
|
||||
<button
|
||||
@click="open = !open"
|
||||
aria-label="User menu for {{ user.get_full_name|default:user.username }}"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="open.toString()"
|
||||
class="relative h-8 w-8 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{% if user.profile.avatar %}
|
||||
<img
|
||||
src="{{ user.profile.avatar.url }}"
|
||||
alt="{{ user.get_full_name|default:user.username }}"
|
||||
<img
|
||||
src="{{ user.profile.avatar.url }}"
|
||||
alt="{{ user.get_full_name|default:user.username }}'s profile picture"
|
||||
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">
|
||||
<div class="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium" aria-hidden="true">
|
||||
{{ user.get_full_name.0|default:user.username.0|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</button>
|
||||
|
||||
<!-- User Dropdown -->
|
||||
<div
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
@@ -201,34 +236,36 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
x-cloak
|
||||
role="menu"
|
||||
aria-label="User account menu"
|
||||
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 items-center justify-start gap-2 p-2" role="presentation">
|
||||
<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>
|
||||
<div class="border-t" role="separator"></div>
|
||||
<a href="{% url 'profile' user.username %}" role="menuitem" class="flex items-center px-2 py-2 text-sm hover:bg-accent focus:bg-accent focus:outline-none">
|
||||
<i class="fas fa-user mr-2 h-4 w-4" aria-hidden="true"></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>
|
||||
<a href="{% url 'settings' %}" role="menuitem" class="flex items-center px-2 py-2 text-sm hover:bg-accent focus:bg-accent focus:outline-none">
|
||||
<i class="fas fa-cog mr-2 h-4 w-4" aria-hidden="true"></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>
|
||||
<a href="{% url 'moderation:dashboard' %}" role="menuitem" class="flex items-center px-2 py-2 text-sm hover:bg-accent focus:bg-accent focus:outline-none">
|
||||
<i class="fas fa-shield-alt mr-2 h-4 w-4" aria-hidden="true"></i>
|
||||
Moderation
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="border-t"></div>
|
||||
<div class="border-t" role="separator"></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>
|
||||
<button type="submit" role="menuitem" class="flex items-center w-full px-2 py-2 text-sm text-red-600 hover:bg-accent focus:bg-accent focus:outline-none">
|
||||
<i class="fas fa-sign-out-alt mr-2 h-4 w-4" aria-hidden="true"></i>
|
||||
Log out
|
||||
</button>
|
||||
</form>
|
||||
@@ -262,50 +299,60 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
<div class="md:hidden flex items-center space-x-2 flex-shrink-0">
|
||||
<!-- Theme Toggle (Mobile) -->
|
||||
<div x-data="themeToggle()">
|
||||
<button
|
||||
<button
|
||||
@click="toggleTheme()"
|
||||
aria-label="Toggle theme"
|
||||
:aria-pressed="$store.theme ? $store.theme.isDark.toString() : '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-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>
|
||||
<i class="fas fa-sun h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" aria-hidden="true"></i>
|
||||
<i class="fas fa-moon absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" aria-hidden="true"></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">
|
||||
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @keydown.escape="open = false">
|
||||
<button
|
||||
@click="open = !open"
|
||||
aria-label="User menu for {{ user.get_full_name|default:user.username }}"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="open.toString()"
|
||||
class="relative h-8 w-8 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{% if user.profile.avatar %}
|
||||
<img
|
||||
src="{{ user.profile.avatar.url }}"
|
||||
alt="{{ user.get_full_name|default:user.username }}"
|
||||
<img
|
||||
src="{{ user.profile.avatar.url }}"
|
||||
alt="{{ user.get_full_name|default:user.username }}'s profile picture"
|
||||
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">
|
||||
<div class="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium" aria-hidden="true">
|
||||
{{ user.get_full_name.0|default:user.username.0|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</button>
|
||||
|
||||
<!-- Mobile User Dropdown -->
|
||||
<div
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition
|
||||
x-cloak
|
||||
role="menu"
|
||||
aria-label="User account menu"
|
||||
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 items-center justify-start gap-2 p-2" role="presentation">
|
||||
<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>
|
||||
<div class="border-t" role="separator"></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>
|
||||
<button type="submit" role="menuitem" class="flex items-center w-full px-2 py-2 text-sm text-red-600 hover:bg-accent focus:bg-accent focus:outline-none">
|
||||
<i class="fas fa-sign-out-alt mr-2 h-4 w-4" aria-hidden="true"></i>
|
||||
Log out
|
||||
</button>
|
||||
</form>
|
||||
@@ -333,16 +380,19 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
{% endif %}
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<div x-data="{ open: false }">
|
||||
<button
|
||||
<div x-data="{ open: false }" @keydown.escape="open = false">
|
||||
<button
|
||||
@click="open = !open"
|
||||
aria-label="Open mobile menu"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="open.toString()"
|
||||
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>
|
||||
<i class="fas fa-bars h-5 w-5" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<!-- Mobile Menu Overlay -->
|
||||
<div
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="transition-opacity ease-linear duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
@@ -353,9 +403,10 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||
@click="open = false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- Mobile Menu Panel -->
|
||||
<div
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-in-out duration-300 transform"
|
||||
x-transition:enter-start="translate-x-full"
|
||||
@@ -363,6 +414,9 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
x-transition:leave="transition ease-in-out duration-300 transform"
|
||||
x-transition:leave-start="translate-x-0"
|
||||
x-transition:leave-end="translate-x-full"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Mobile navigation"
|
||||
class="fixed right-0 top-0 h-full w-full sm:w-96 bg-background border-l shadow-lg"
|
||||
@click.stop
|
||||
>
|
||||
@@ -370,58 +424,59 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
<!-- 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">
|
||||
<div class="w-6 h-6 bg-purple-600 rounded flex items-center justify-center" aria-hidden="true">
|
||||
<span class="text-white text-xs font-bold">TW</span>
|
||||
</div>
|
||||
<span class="font-bold text-lg">ThrillWiki</span>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
@click="open = false"
|
||||
aria-label="Close mobile menu"
|
||||
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>
|
||||
<i class="fas fa-times h-5 w-5" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<nav class="flex-1 overflow-y-auto p-4 space-y-6" aria-label="Mobile navigation">
|
||||
<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">
|
||||
<h3 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3" id="mobile-nav-heading">
|
||||
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>
|
||||
<div class="space-y-1" role="list" aria-labelledby="mobile-nav-heading">
|
||||
<a href="{% url 'home' %}" role="listitem" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent focus:bg-accent focus:outline-none transition-colors" @click="open = false">
|
||||
<i class="fas fa-home w-4 h-4" aria-hidden="true"></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>
|
||||
<a href="{% url 'search:search' %}" role="listitem" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent focus:bg-accent focus:outline-none transition-colors" @click="open = false">
|
||||
<i class="fas fa-search w-4 h-4" aria-hidden="true"></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>
|
||||
<a href="{% url 'parks:park_list' %}" role="listitem" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent focus:bg-accent focus:outline-none transition-colors" @click="open = false">
|
||||
<i class="fas fa-map-marker-alt w-4 h-4" aria-hidden="true"></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>
|
||||
<a href="{% url 'rides:global_ride_list' %}" role="listitem" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent focus:bg-accent focus:outline-none transition-colors" @click="open = false">
|
||||
<i class="fas fa-rocket w-4 h-4" aria-hidden="true"></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>
|
||||
<a href="{% url 'rides:manufacturer_list' %}" role="listitem" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent focus:bg-accent focus:outline-none transition-colors" @click="open = false">
|
||||
<i class="fas fa-wrench w-4 h-4" aria-hidden="true"></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>
|
||||
<a href="{% url 'parks:operator_list' %}" role="listitem" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent focus:bg-accent focus:outline-none transition-colors" @click="open = false">
|
||||
<i class="fas fa-building w-4 h-4" aria-hidden="true"></i>
|
||||
<span>Operators</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -430,11 +485,13 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
</div>
|
||||
|
||||
<!-- Mobile Search Bar -->
|
||||
<div class="md:hidden border-t bg-background">
|
||||
<div class="md:hidden border-t bg-background" role="search">
|
||||
<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>
|
||||
<label for="mobile-search" class="sr-only">Search parks and rides</label>
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" aria-hidden="true"></i>
|
||||
<input
|
||||
id="mobile-search"
|
||||
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"
|
||||
@@ -443,10 +500,14 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
hx-target="#mobile-search-results"
|
||||
hx-include="this"
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
aria-describedby="mobile-search-results-status"
|
||||
aria-controls="mobile-search-results"
|
||||
/>
|
||||
{% 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 id="mobile-search-results" role="listbox" aria-label="Mobile search results" aria-live="polite" class="mt-2"></div>
|
||||
<div id="mobile-search-results-status" class="sr-only" aria-live="polite" aria-atomic="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
50
backend/templates/components/lazy_image.html
Normal file
50
backend/templates/components/lazy_image.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{#
|
||||
Reusable lazy loading image component with progressive enhancement.
|
||||
|
||||
Parameters:
|
||||
- image_url: The URL of the image to load
|
||||
- alt_text: Alt text for accessibility (required)
|
||||
- css_classes: Additional CSS classes (optional)
|
||||
- width: Image width (optional)
|
||||
- height: Image height (optional)
|
||||
- loading: "lazy" (default) or "eager" for above-fold images
|
||||
- placeholder: Custom placeholder URL (optional, defaults to inline SVG)
|
||||
- srcset: Responsive image srcset (optional)
|
||||
- sizes: Responsive image sizes (optional)
|
||||
- full_src: Full-size image URL for lightbox (optional)
|
||||
|
||||
Usage:
|
||||
{% include "components/lazy_image.html" with image_url=park.image_url alt_text=park.name %}
|
||||
{% include "components/lazy_image.html" with image_url=ride.thumbnail alt_text=ride.name loading="eager" %}
|
||||
{% include "components/lazy_image.html" with image_url=photo.url alt_text=photo.caption css_classes="rounded-lg shadow" %}
|
||||
|
||||
Cloudflare Images responsive example:
|
||||
{% include "components/lazy_image.html" with
|
||||
image_url=photo.url|add:"?width=800"
|
||||
srcset=photo.url|add:"?width=400 400w, "|add:photo.url|add:"?width=800 800w, "|add:photo.url|add:"?width=1200 1200w"
|
||||
sizes="(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px"
|
||||
alt_text=photo.caption
|
||||
%}
|
||||
#}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{# Default placeholder: inline SVG with aspect ratio preservation #}
|
||||
{% with placeholder_default="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 9'%3E%3Crect fill='%23f3f4f6' width='16' height='9'/%3E%3C/svg%3E" %}
|
||||
|
||||
<img
|
||||
src="{{ placeholder|default:placeholder_default }}"
|
||||
data-src="{{ image_url }}"
|
||||
{% if srcset %}data-srcset="{{ srcset }}"{% endif %}
|
||||
{% if sizes %}sizes="{{ sizes }}"{% endif %}
|
||||
alt="{{ alt_text|default:'' }}"
|
||||
class="lazy {{ css_classes|default:'' }}"
|
||||
loading="{{ loading|default:'lazy' }}"
|
||||
{% if width %}width="{{ width }}"{% endif %}
|
||||
{% if height %}height="{{ height }}"{% endif %}
|
||||
{% if full_src %}data-full-src="{{ full_src }}"{% endif %}
|
||||
decoding="async"
|
||||
fetchpriority="{% if loading == 'eager' %}high{% else %}low{% endif %}"
|
||||
>
|
||||
|
||||
{% endwith %}
|
||||
@@ -10,28 +10,23 @@ Purpose:
|
||||
and proper ARIA attributes for accessibility.
|
||||
|
||||
Usage Examples:
|
||||
Basic modal:
|
||||
{% include 'components/modals/modal_base.html' with modal_id='my-modal' title='Modal Title' %}
|
||||
{% block modal_body %}
|
||||
<p>Modal content here</p>
|
||||
{% endblock %}
|
||||
{% endinclude %}
|
||||
Basic modal (extending):
|
||||
{% extends 'components/modals/modal_base.html' %}
|
||||
{% block modal_body %}
|
||||
<p>Modal content here</p>
|
||||
{% endblock %}
|
||||
|
||||
Modal with footer:
|
||||
<div x-data="{ showModal: false }">
|
||||
<button @click="showModal = true">Open Modal</button>
|
||||
{% include 'components/modals/modal_base.html' with modal_id='confirm-modal' title='Confirm Action' show_var='showModal' %}
|
||||
{% block modal_body %}
|
||||
<p>Are you sure?</p>
|
||||
{% endblock %}
|
||||
{% block modal_footer %}
|
||||
<button @click="showModal = false" class="btn-secondary">Cancel</button>
|
||||
<button @click="confirmAction(); showModal = false" class="btn-primary">Confirm</button>
|
||||
{% endblock %}
|
||||
{% endinclude %}
|
||||
</div>
|
||||
{% extends 'components/modals/modal_base.html' %}
|
||||
{% block modal_body %}
|
||||
<p>Are you sure?</p>
|
||||
{% endblock %}
|
||||
{% block modal_footer %}
|
||||
<button @click="showModal = false" class="btn-secondary">Cancel</button>
|
||||
<button @click="confirmAction(); showModal = false" class="btn-primary">Confirm</button>
|
||||
{% endblock %}
|
||||
|
||||
Different sizes:
|
||||
Include-based usage (pass context via with):
|
||||
{% include 'components/modals/modal_base.html' with modal_id='lg-modal' title='Large Modal' size='lg' %}
|
||||
|
||||
Parameters:
|
||||
@@ -66,7 +61,7 @@ Accessibility:
|
||||
{% endcomment %}
|
||||
|
||||
{# Default values #}
|
||||
{% with size=size|default:'md' show_close_button=show_close_button|default:True show_var=show_var|default:'show' close_on_backdrop=close_on_backdrop|default:True close_on_escape=close_on_escape|default:True prevent_scroll=prevent_scroll|default:True %}
|
||||
{% with size=size|default:'md' show_close_button=show_close_button|default:True show_var=show_var|default:'show' close_on_backdrop=close_on_backdrop|default:True close_on_escape=close_on_escape|default:True prevent_scroll=prevent_scroll|default:True animation=animation|default:'scale' loading=loading|default:False %}
|
||||
|
||||
{# Size classes mapping #}
|
||||
{% if size == 'sm' %}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{% extends "components/modals/modal_base.html" %}
|
||||
|
||||
{% block modal_content %}
|
||||
{% block modal_body %}
|
||||
<form hx-post="{{ action }}" hx-target="#modal-container" hx-swap="outerHTML">
|
||||
{% for field in form %}
|
||||
{% include "forms/partials/form_field.html" with field=field %}
|
||||
{% endfor %}
|
||||
{% include "forms/partials/form_actions.html" %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% endblock modal_body %}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
{# Inner modal template - do not use directly, use modal_base.html instead #}
|
||||
{# Enhanced with animations, focus trap, and loading states #}
|
||||
{% comment %}
|
||||
ACCESSIBILITY FEATURES:
|
||||
- Focus trap: Tab/Shift+Tab cycles through focusable elements within modal
|
||||
- Home/End keys: Jump to first/last focusable element
|
||||
- Escape key: Close modal (configurable via close_on_escape)
|
||||
- aria-modal="true": Indicates modal dialog semantics
|
||||
- aria-labelledby: References modal title for screen readers
|
||||
- aria-describedby: References modal body and subtitle for description
|
||||
- Automatic focus: First focusable element receives focus on open
|
||||
- Focus restoration: Focus returns to trigger element on close (handled by parent)
|
||||
{% endcomment %}
|
||||
|
||||
{% with animation=animation|default:'scale' loading=loading|default:False %}
|
||||
|
||||
@@ -30,11 +41,19 @@
|
||||
return true;
|
||||
}
|
||||
"
|
||||
@keydown.home.prevent="
|
||||
const focusables = $el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');
|
||||
if (focusables[0]) focusables[0].focus();
|
||||
"
|
||||
@keydown.end.prevent="
|
||||
const focusables = $el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');
|
||||
if (focusables[focusables.length - 1]) focusables[focusables.length - 1].focus();
|
||||
"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
{% if title %}aria-labelledby="{{ modal_id }}-title"{% endif %}
|
||||
aria-describedby="{{ modal_id }}-body">
|
||||
{% if subtitle %}aria-describedby="{{ modal_id }}-subtitle {{ modal_id }}-body"{% else %}aria-describedby="{{ modal_id }}-body"{% endif %}>
|
||||
|
||||
{# Backdrop #}
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
@@ -103,7 +122,7 @@
|
||||
{{ title }}
|
||||
</h3>
|
||||
{% if subtitle %}
|
||||
<p class="text-sm text-muted-foreground">{{ subtitle }}</p>
|
||||
<p id="{{ modal_id }}-subtitle" class="text-sm text-muted-foreground">{{ subtitle }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,8 +134,8 @@
|
||||
{% if show_close_button %}
|
||||
<button type="button"
|
||||
@click="{{ show_var }} = false"
|
||||
class="p-2 -mr-2 text-muted-foreground hover:text-foreground rounded-lg hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring transition-colors"
|
||||
aria-label="Close modal">
|
||||
class="p-2 -mr-2 text-muted-foreground hover:text-foreground rounded-lg hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 transition-colors"
|
||||
aria-label="Close {{ title|default:'modal' }}">
|
||||
<i class="fas fa-times text-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
@@ -97,13 +97,14 @@ Accessibility:
|
||||
|
||||
{# Mobile ellipsis for long breadcrumb trails #}
|
||||
{% if forloop.counter == 1 and items|length > max_visible %}
|
||||
<li class="flex items-center sm:hidden" aria-hidden="true">
|
||||
<span class="mx-2 text-muted-foreground/50">
|
||||
<li class="flex items-center sm:hidden" aria-label="Additional navigation items hidden on mobile">
|
||||
<span class="mx-2 text-muted-foreground/50" aria-hidden="true">
|
||||
<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="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-muted-foreground">...</span>
|
||||
<span class="text-muted-foreground" aria-hidden="true">...</span>
|
||||
<span class="sr-only">{{ items|length|add:'-2' }} items hidden</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -4,6 +4,16 @@ Button Component - Unified Django Template Version of shadcn/ui Button
|
||||
A versatile button component that supports multiple variants, sizes, icons, and both
|
||||
button/link elements. Compatible with HTMX and Alpine.js.
|
||||
|
||||
ACCESSIBILITY REQUIREMENT:
|
||||
Icon-only buttons (size='icon') MUST include aria_label parameter.
|
||||
This is required for screen reader users to understand the button's purpose.
|
||||
|
||||
Correct usage:
|
||||
{% include 'components/ui/button.html' with size='icon' icon=close_svg aria_label='Close dialog' %}
|
||||
|
||||
INCORRECT - will be inaccessible:
|
||||
{% include 'components/ui/button.html' with size='icon' icon=close_svg %}
|
||||
|
||||
Usage Examples:
|
||||
Basic button:
|
||||
{% include 'components/ui/button.html' with text='Click me' %}
|
||||
@@ -23,7 +33,7 @@ Usage Examples:
|
||||
With SVG icon (preferred):
|
||||
{% include 'components/ui/button.html' with icon=search_icon_svg text='Search' %}
|
||||
|
||||
Icon-only button:
|
||||
Icon-only button (REQUIRES aria_label):
|
||||
{% include 'components/ui/button.html' with icon=icon_svg size='icon' aria_label='Close' %}
|
||||
|
||||
Parameters:
|
||||
@@ -40,12 +50,18 @@ Parameters:
|
||||
- disabled: Boolean to disable the button
|
||||
- class: Additional CSS classes
|
||||
- id: Element ID
|
||||
- aria_label: Accessibility label (required for icon-only buttons)
|
||||
- aria_label: Accessibility label (REQUIRED for icon-only buttons)
|
||||
- onclick: JavaScript click handler
|
||||
- hx_get, hx_post, hx_target, hx_swap, hx_trigger, hx_indicator, hx_include: HTMX attributes
|
||||
- x_data, x_on_click, x_bind, x_show: Alpine.js attributes
|
||||
- attrs: Additional HTML attributes as string
|
||||
|
||||
Accessibility Features:
|
||||
- Focus ring: Visible focus indicator (focus-visible:ring-2)
|
||||
- Disabled state: Proper disabled attribute and opacity change
|
||||
- ARIA label: Required for icon-only buttons for screen reader support
|
||||
- Keyboard accessible: All buttons are focusable and activatable via keyboard
|
||||
|
||||
Security: Icon SVGs are sanitized using the sanitize_svg filter to prevent XSS attacks.
|
||||
{% endcomment %}
|
||||
|
||||
|
||||
303
backend/templates/core/performance_dashboard.html
Normal file
303
backend/templates/core/performance_dashboard.html
Normal file
@@ -0,0 +1,303 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Performance Dashboard - ThrillWiki Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Performance Dashboard</h1>
|
||||
<p class="text-muted-foreground">Monitor application performance and cache statistics</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick="refreshMetrics()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-primary rounded-md hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
>
|
||||
<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 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>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4" id="quick-stats">
|
||||
<!-- Database Status -->
|
||||
<div class="bg-card rounded-lg border border-border p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-success-100 text-success-700 rounded-lg">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Database</p>
|
||||
<p class="text-lg font-semibold">{{ database_stats.engine }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ database_stats.active_connections|default:"N/A" }} connections</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Hit Rate -->
|
||||
<div class="bg-card rounded-lg border border-border p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-info-100 text-info-700 rounded-lg">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Cache Hit Rate</p>
|
||||
<p class="text-lg font-semibold" id="cache-hit-rate">
|
||||
{% if cache_stats.default.hit_rate %}
|
||||
{{ cache_stats.default.hit_rate }}
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">Default cache</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Middleware -->
|
||||
<div class="bg-card rounded-lg border border-border p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-warning-100 text-warning-700 rounded-lg">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Middleware</p>
|
||||
<p class="text-lg font-semibold">{{ middleware_config.count }} active</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{% if middleware_config.has_gzip %}GZip{% endif %}
|
||||
{% if middleware_config.has_performance %}+ Perf{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection Pool -->
|
||||
<div class="bg-card rounded-lg border border-border p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary-100 text-primary-700 rounded-lg">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">DB Conn Max Age</p>
|
||||
<p class="text-lg font-semibold">{{ database_stats.conn_max_age }}s</p>
|
||||
<p class="text-xs text-muted-foreground">Connection pooling</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Statistics -->
|
||||
<div class="bg-card rounded-lg border border-border">
|
||||
<div class="px-6 py-4 border-b border-border">
|
||||
<h2 class="text-lg font-semibold">Cache Statistics</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for cache_name, stats in cache_stats.items %}
|
||||
<div class="p-4 bg-muted/50 rounded-lg">
|
||||
<h3 class="font-medium mb-3 capitalize">{{ cache_name }}</h3>
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-muted-foreground">Backend</dt>
|
||||
<dd class="font-medium">{{ stats.backend|default:"N/A" }}</dd>
|
||||
</div>
|
||||
{% if stats.hit_rate %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-muted-foreground">Hit Rate</dt>
|
||||
<dd class="font-medium text-success-600">{{ stats.hit_rate }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if stats.used_memory_human %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-muted-foreground">Memory Used</dt>
|
||||
<dd class="font-medium">{{ stats.used_memory_human }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if stats.connected_clients %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-muted-foreground">Connections</dt>
|
||||
<dd class="font-medium">{{ stats.connected_clients }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if stats.keyspace_hits %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-muted-foreground">Hits / Misses</dt>
|
||||
<dd class="font-medium">{{ stats.keyspace_hits|floatformat:0 }} / {{ stats.keyspace_misses|floatformat:0 }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if stats.error %}
|
||||
<div class="text-error-600">
|
||||
Error: {{ stats.error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-muted-foreground">No cache statistics available.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Configuration -->
|
||||
<div class="bg-card rounded-lg border border-border">
|
||||
<div class="px-6 py-4 border-b border-border">
|
||||
<h2 class="text-lg font-semibold">Cache Configuration</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="px-4 py-2 text-left font-medium text-muted-foreground">Cache</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-muted-foreground">Backend</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-muted-foreground">Location</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-muted-foreground">Key Prefix</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-muted-foreground">Max Connections</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cache_name, config in cache_config.items %}
|
||||
<tr class="border-b border-border last:border-0">
|
||||
<td class="px-4 py-3 font-medium capitalize">{{ cache_name }}</td>
|
||||
<td class="px-4 py-3">{{ config.backend }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs">{{ config.location }}</td>
|
||||
<td class="px-4 py-3">{{ config.key_prefix|default:"None" }}</td>
|
||||
<td class="px-4 py-3">{{ config.max_connections|default:"N/A" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Middleware Configuration -->
|
||||
<div class="bg-card rounded-lg border border-border">
|
||||
<div class="px-6 py-4 border-b border-border">
|
||||
<h2 class="text-lg font-semibold">Middleware Stack</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for middleware in middleware_config.middleware_list %}
|
||||
<span class="inline-flex items-center px-3 py-1 text-xs font-medium rounded-full
|
||||
{% if 'cache' in middleware|lower %}bg-info-100 text-info-800
|
||||
{% elif 'gzip' in middleware|lower %}bg-success-100 text-success-800
|
||||
{% elif 'performance' in middleware|lower %}bg-warning-100 text-warning-800
|
||||
{% elif 'security' in middleware|lower %}bg-error-100 text-error-800
|
||||
{% else %}bg-muted text-muted-foreground{% endif %}">
|
||||
{{ middleware|truncatechars:50 }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Tips -->
|
||||
<div class="bg-card rounded-lg border border-border">
|
||||
<div class="px-6 py-4 border-b border-border">
|
||||
<h2 class="text-lg font-semibold">Performance Optimization Status</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-center gap-3">
|
||||
{% if middleware_config.has_gzip %}
|
||||
<span class="w-5 h-5 rounded-full bg-success-500 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-success-700">GZip compression enabled</span>
|
||||
{% else %}
|
||||
<span class="w-5 h-5 rounded-full bg-warning-500 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-warning-700">GZip compression not enabled</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
|
||||
<li class="flex items-center gap-3">
|
||||
{% if middleware_config.has_cache_update and middleware_config.has_cache_fetch %}
|
||||
<span class="w-5 h-5 rounded-full bg-success-500 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-success-700">Page-level caching middleware enabled</span>
|
||||
{% else %}
|
||||
<span class="w-5 h-5 rounded-full bg-warning-500 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-warning-700">Page-level caching middleware not complete</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
|
||||
<li class="flex items-center gap-3">
|
||||
{% if middleware_config.has_performance %}
|
||||
<span class="w-5 h-5 rounded-full bg-success-500 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-success-700">Performance monitoring middleware enabled</span>
|
||||
{% else %}
|
||||
<span class="w-5 h-5 rounded-full bg-warning-500 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-warning-700">Performance monitoring middleware not enabled</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
|
||||
<li class="flex items-center gap-3">
|
||||
{% if database_stats.conn_max_age > 0 %}
|
||||
<span class="w-5 h-5 rounded-full bg-success-500 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-success-700">Database connection pooling enabled ({{ database_stats.conn_max_age }}s)</span>
|
||||
{% else %}
|
||||
<span class="w-5 h-5 rounded-full bg-warning-500 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-warning-700">Database connection pooling not enabled</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function refreshMetrics() {
|
||||
// Reload the page to get fresh metrics
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
// setInterval(refreshMetrics, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,10 @@
|
||||
<div class="divide-y">
|
||||
<div class="divide-y" role="listbox" aria-label="Search results">
|
||||
{% for object in object_list %}
|
||||
<div class="p-4">
|
||||
<div id="result-option-{{ forloop.counter0 }}"
|
||||
role="option"
|
||||
aria-selected="false"
|
||||
tabindex="-1"
|
||||
class="p-4 cursor-pointer">
|
||||
<h3 class="text-lg font-semibold">
|
||||
<a href="{{ object.get_absolute_url }}" class="hover:text-blue-600">
|
||||
{{ object }}
|
||||
@@ -36,11 +40,11 @@
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
<div class="p-8 text-center text-gray-500" role="status" aria-live="polite">
|
||||
<p>No {{ view.model|model_name_plural }} found matching your criteria.</p>
|
||||
{% if applied_filters %}
|
||||
<p class="mt-2">
|
||||
<a href="{{ request.path }}"
|
||||
<a href="{{ request.path }}"
|
||||
class="text-blue-600 hover:text-blue-500"
|
||||
hx-get="{{ request.path }}"
|
||||
hx-target="#results-container"
|
||||
@@ -93,7 +97,7 @@
|
||||
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">
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<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>
|
||||
@@ -101,7 +105,7 @@
|
||||
|
||||
{% 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">
|
||||
<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" aria-current="page">
|
||||
{{ i }}
|
||||
</span>
|
||||
{% elif i > page_obj.number|add:"-3" and i < page_obj.number|add:"3" %}
|
||||
@@ -122,7 +126,7 @@
|
||||
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">
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<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>
|
||||
@@ -131,4 +135,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,45 +1,49 @@
|
||||
{% load static %}
|
||||
|
||||
<div id="ride-search-results" class="mt-4">
|
||||
<div id="ride-search-results" class="mt-4" role="listbox" aria-label="Search results">
|
||||
{% if rides %}
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400" aria-live="polite">
|
||||
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 id="ride-option-{{ forloop.counter0 }}"
|
||||
role="option"
|
||||
aria-selected="false"
|
||||
tabindex="-1"
|
||||
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 cursor-pointer">
|
||||
<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 %}"
|
||||
<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 %}"
|
||||
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
|
||||
<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 %}">
|
||||
@@ -48,11 +52,11 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if ride.photos.exists %}
|
||||
<div class="ml-4 shrink-0">
|
||||
{% with ride.photos.first as photo %}
|
||||
<img src="{{ photo.image.url }}"
|
||||
<img src="{{ photo.image.url }}"
|
||||
alt="{{ ride.name }}"
|
||||
class="w-16 h-16 rounded-lg object-cover">
|
||||
{% endwith %}
|
||||
@@ -63,9 +67,9 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8">
|
||||
<div class="text-center py-8" role="status" aria-live="polite">
|
||||
<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">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" 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>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No rides found</h3>
|
||||
@@ -75,4 +79,4 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div id="search-dropdown" class="search-dropdown">
|
||||
<div id="search-dropdown" class="search-dropdown" role="listbox" aria-label="Search results">
|
||||
{% include "core/search/partials/search_suggestions.html" %}
|
||||
<div id="search-results">
|
||||
{% for item in results %}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<div class="search-result-item">
|
||||
<div id="search-option-{{ forloop.counter0|default:item.id }}"
|
||||
role="option"
|
||||
aria-selected="false"
|
||||
tabindex="-1"
|
||||
class="search-result-item cursor-pointer">
|
||||
<a href="{{ item.url }}">{{ item.title }}</a>
|
||||
<div class="muted">{{ item.subtitle }}</div>
|
||||
</div>
|
||||
|
||||
@@ -51,9 +51,10 @@ Accessibility:
|
||||
{% endcomment %}
|
||||
|
||||
{# Support both 'inline' param and 'mode' param #}
|
||||
{% with actual_mode=mode|default:inline|yesno:'inline,block' %}
|
||||
{# Priority: explicit mode > inline shortcut > default to 'block' #}
|
||||
{# Determine actual_mode: use mode if provided, else if inline is true use 'inline', else default to 'block' #}
|
||||
|
||||
{% if actual_mode == 'overlay' %}
|
||||
{% if mode == 'overlay' %}
|
||||
{# ============================================
|
||||
Overlay Mode - Covers parent element
|
||||
Parent must have position: relative
|
||||
@@ -78,7 +79,7 @@ Accessibility:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif actual_mode == 'inline' or inline %}
|
||||
{% elif mode == 'inline' or inline %}
|
||||
{# ============================================
|
||||
Inline Mode - For use within buttons/links
|
||||
============================================ #}
|
||||
@@ -121,5 +122,3 @@ Accessibility:
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endwith %}
|
||||
|
||||
@@ -26,63 +26,52 @@
|
||||
{% if is_edit %}Edit{% else %}Create{% endif %} Park
|
||||
</h1>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="parkForm()">
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-6" role="form" aria-label="{% if is_edit %}Edit park{% else %}Create new park{% endif %}" 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>
|
||||
<fieldset class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<legend class="mb-4 text-xl font-semibold">Basic Information</legend>
|
||||
<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 }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.name label="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 }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.description label="Description" %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Owner/Operator
|
||||
</label>
|
||||
{{ form.owner }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.owner label="Owner/Operator" %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status
|
||||
</label>
|
||||
{{ form.status }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.status label="Status" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{# Location #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Location</h2>
|
||||
<fieldset class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<legend class="mb-4 text-xl font-semibold">Location</legend>
|
||||
{% include "parks/partials/location_widget.html" %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{# Photos #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Photos</h2>
|
||||
|
||||
<fieldset class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<legend class="mb-4 text-xl font-semibold">Photos</legend>
|
||||
|
||||
{# 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 }}"
|
||||
<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"
|
||||
<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>
|
||||
@click="removePhoto('{{ photo.id }}')"
|
||||
aria-label="Remove photo">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,121 +81,114 @@
|
||||
|
||||
{# Photo Upload #}
|
||||
<div class="space-y-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" id="photo-upload-label">
|
||||
Add Photos
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="file"
|
||||
multiple
|
||||
<input type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
x-ref="fileInput"
|
||||
@change="handleFileSelect">
|
||||
@change="handleFileSelect"
|
||||
aria-labelledby="photo-upload-label">
|
||||
<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>
|
||||
<i class="mr-2 fas fa-upload" aria-hidden="true"></i>
|
||||
Click to upload photos
|
||||
</span>
|
||||
<span x-show="previews.length">
|
||||
<i class="mr-2 fas fa-plus"></i>
|
||||
<i class="mr-2 fas fa-plus" aria-hidden="true"></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">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3" role="list" aria-label="Photo previews">
|
||||
<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"
|
||||
role="listitem"
|
||||
:class="{
|
||||
'uploading': preview.uploading,
|
||||
'error': preview.error,
|
||||
'success': preview.uploaded
|
||||
}">
|
||||
<img :src="preview.url"
|
||||
class="object-cover w-full h-full">
|
||||
<img :src="preview.url"
|
||||
class="object-cover w-full h-full"
|
||||
alt="Preview image">
|
||||
<div class="absolute top-0 right-0 p-2">
|
||||
<button type="button"
|
||||
<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>
|
||||
@click="removePreview(index)"
|
||||
aria-label="Remove preview">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="preview.uploading"
|
||||
<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 class="w-16 h-16 border-4 border-blue-500 rounded-full animate-spin border-t-transparent" role="status" aria-label="Uploading"></div>
|
||||
</div>
|
||||
<div x-show="preview.error"
|
||||
class="absolute bottom-0 left-0 right-0 p-2 text-sm text-white bg-red-500">
|
||||
<div x-show="preview.error"
|
||||
class="absolute bottom-0 left-0 right-0 p-2 text-sm text-white bg-red-500" role="alert">
|
||||
Upload failed
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{# 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>
|
||||
<fieldset class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<legend class="mb-4 text-xl font-semibold">Additional Details</legend>
|
||||
<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 }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.opening_date label="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 }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.closing_date label="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 }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.operating_season label="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 }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.size_acres label="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 }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.website label="Website" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{# 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>
|
||||
<fieldset class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<legend class="mb-4 text-xl font-semibold">Submission Details</legend>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label for="id_reason" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Reason for Changes
|
||||
</label>
|
||||
<textarea name="reason" rows="2"
|
||||
<textarea name="reason" id="id_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>
|
||||
placeholder="Explain why you're making these changes"
|
||||
aria-describedby="reason-help"></textarea>
|
||||
<p id="reason-help" class="mt-1 text-xs text-gray-500 dark:text-gray-400">Optional: Provide context for reviewers</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label for="id_source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Source
|
||||
</label>
|
||||
<input type="text" name="source"
|
||||
<input type="text" name="source" id="id_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?">
|
||||
placeholder="Where did you get this information?"
|
||||
aria-describedby="source-help">
|
||||
<p id="source-help" class="mt-1 text-xs text-gray-500 dark:text-gray-400">Optional: Link or reference to your source</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{# Submit Button #}
|
||||
<div class="flex justify-end">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="{
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-6" role="form" aria-label="{% if is_edit %}Edit ride{% else %}Add new ride{% endif %}" x-data="{
|
||||
status: '{{ form.instance.status|default:'OPERATING' }}',
|
||||
clearResults(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
@@ -39,199 +39,146 @@
|
||||
|
||||
{% if not park %}
|
||||
{# Park Selection - Only shown when creating from global view #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Park Information</h2>
|
||||
<fieldset class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<legend class="mb-4 text-xl font-semibold">Park Information</legend>
|
||||
<div class="space-y-4">
|
||||
<div id="park-search-container" class="relative" @click.outside="clearResults('park-search-container')">
|
||||
<label for="{{ form.park_search.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Park *
|
||||
</label>
|
||||
{{ form.park_search }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.park_search label="Park" %}
|
||||
{{ form.park }}
|
||||
<div id="park-search-results" class="relative"></div>
|
||||
<div id="park-search-results" class="relative" role="listbox" aria-label="Park search results"></div>
|
||||
{% if form.park.errors %}
|
||||
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||
<div class="mt-1 text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
{{ form.park.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
|
||||
{# Basic Information #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Basic Information</h2>
|
||||
<fieldset class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<legend class="mb-4 text-xl font-semibold">Basic Information</legend>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="col-span-2">
|
||||
<label for="{{ form.name.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Name *
|
||||
</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}
|
||||
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.name.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include 'forms/partials/form_field.html' with field=form.name label="Name" %}
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<label for="{{ form.category.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Category *
|
||||
</label>
|
||||
{{ form.category }}
|
||||
{% if form.category.errors %}
|
||||
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.category.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include 'forms/partials/form_field.html' with field=form.category label="Category" %}
|
||||
</div>
|
||||
|
||||
<div id="coaster-fields" class="col-span-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{# Manufacturer and Model #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Manufacturer and Model</h2>
|
||||
<fieldset class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<legend class="mb-4 text-xl font-semibold">Manufacturer and Model</legend>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div id="manufacturer-search-container" class="relative" @click.outside="clearResults('manufacturer-search-container')">
|
||||
<label for="{{ form.manufacturer_search.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Manufacturer
|
||||
</label>
|
||||
{{ form.manufacturer_search }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.manufacturer_search label="Manufacturer" %}
|
||||
{{ form.manufacturer }}
|
||||
<div id="manufacturer-search-results" class="relative"></div>
|
||||
<div id="manufacturer-search-results" class="relative" role="listbox" aria-label="Manufacturer search results"></div>
|
||||
</div>
|
||||
|
||||
<div id="designer-search-container" class="relative" @click.outside="clearResults('designer-search-container')">
|
||||
<label for="{{ form.designer_search.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Designer
|
||||
</label>
|
||||
{{ form.designer_search }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.designer_search label="Designer" %}
|
||||
{{ form.designer }}
|
||||
<div id="designer-search-results" class="relative"></div>
|
||||
<div id="designer-search-results" class="relative" role="listbox" aria-label="Designer search results"></div>
|
||||
</div>
|
||||
|
||||
<div id="ride-model-search-container" class="relative col-span-2" @click.outside="clearResults('ride-model-search-container')">
|
||||
<label for="{{ form.ride_model_search.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Ride Model
|
||||
</label>
|
||||
{{ form.ride_model_search }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.ride_model_search label="Ride Model" %}
|
||||
{{ form.ride_model }}
|
||||
<div id="ride-model-search-results" class="relative"></div>
|
||||
<div id="ride-model-search-results" class="relative" role="listbox" aria-label="Ride model search results"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{# Status and Dates #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Status and Dates</h2>
|
||||
<fieldset class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<legend class="mb-4 text-xl font-semibold">Status and Dates</legend>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="{{ form.status.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status
|
||||
</label>
|
||||
{{ form.status }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.status label="Status" %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ form.status_since.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status Since
|
||||
</label>
|
||||
{{ form.status_since }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.status_since label="Status Since" %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ form.opening_date.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Opening Date
|
||||
</label>
|
||||
{{ form.opening_date }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.opening_date label="Opening Date" %}
|
||||
</div>
|
||||
|
||||
<div x-show="showClosingDate()">
|
||||
<label for="{{ form.closing_date.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Closing Date <span x-show="status === 'CLOSING'" class="text-red-600">*</span>
|
||||
</label>
|
||||
{{ form.closing_date }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.closing_date label="Closing Date" %}
|
||||
<p x-show="status === 'CLOSING'" class="mt-1 text-xs text-red-600" role="alert">Required when status is "Closing"</p>
|
||||
</div>
|
||||
|
||||
<div x-show="status === 'CLOSING'" class="col-span-2">
|
||||
<label for="{{ form.post_closing_status.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status After Closing *
|
||||
</label>
|
||||
{{ form.post_closing_status }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.post_closing_status label="Status After Closing" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{# Specifications #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Specifications</h2>
|
||||
<fieldset class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<legend class="mb-4 text-xl font-semibold">Specifications</legend>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="{{ form.min_height_in.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Minimum Height (inches)
|
||||
</label>
|
||||
{{ form.min_height_in }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.min_height_in label="Minimum Height (inches)" %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ form.max_height_in.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Maximum Height (inches)
|
||||
</label>
|
||||
{{ form.max_height_in }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.max_height_in label="Maximum Height (inches)" %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ form.capacity_per_hour.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Hourly Capacity
|
||||
</label>
|
||||
{{ form.capacity_per_hour }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.capacity_per_hour label="Hourly Capacity" %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ form.ride_duration_seconds.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Ride Duration (seconds)
|
||||
</label>
|
||||
{{ form.ride_duration_seconds }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.ride_duration_seconds label="Ride Duration (seconds)" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{# Description #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Description</h2>
|
||||
<fieldset class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<legend class="mb-4 text-xl font-semibold">Description</legend>
|
||||
<div>
|
||||
<label for="{{ form.description.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Description
|
||||
</label>
|
||||
{{ form.description }}
|
||||
{% include 'forms/partials/form_field.html' with field=form.description label="Description" %}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{# 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>
|
||||
<fieldset class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<legend class="mb-4 text-xl font-semibold">Submission Details</legend>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label for="id_reason" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Reason for Changes
|
||||
</label>
|
||||
<textarea name="reason" rows="2"
|
||||
<textarea name="reason" id="id_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>
|
||||
placeholder="Explain why you're making these changes"
|
||||
aria-describedby="reason-help"></textarea>
|
||||
<p id="reason-help" class="mt-1 text-xs text-gray-500 dark:text-gray-400">Optional: Provide context for reviewers</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label for="id_source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Source
|
||||
</label>
|
||||
<input type="text" name="source"
|
||||
<input type="text" name="source" id="id_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?">
|
||||
placeholder="Where did you get this information?"
|
||||
aria-describedby="source-help">
|
||||
<p id="source-help" class="mt-1 text-xs text-gray-500 dark:text-gray-400">Optional: Link or reference to your source</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{# Submit Button #}
|
||||
<div class="flex justify-end">
|
||||
|
||||
Reference in New Issue
Block a user