feat: Implement enhanced park list template with improved layout and accessibility features

- Created a new enhanced park list template with a responsive design.
- Added skip navigation links for better accessibility.
- Introduced an enhanced header section with park statistics overview.
- Developed a sidebar for advanced filters and a search section.
- Implemented loading overlay and error handling for HTMX requests.
- Enhanced park results display with animations and improved empty states.
- Added pagination controls with improved UX for navigating park listings.
This commit is contained in:
pacnpal
2025-09-23 20:35:44 -04:00
parent fd42ee1161
commit 41fb41838c
14 changed files with 1716 additions and 44 deletions

View File

@@ -1,4 +1,5 @@
<thrillwiki_context>
<?xml version="1.0" encoding="UTF-8"?>
<thrillwiki_context version="1.0">
<!-- Core Project Information -->
<project_overview>
<name>ThrillWiki</name>
@@ -209,4 +210,56 @@
<database_management>Always use uv run for Django management commands</database_management>
<testing>All functionality must work with progressive enhancement</testing>
</workflow>
<!-- Context7 MCP Integration (MANDATORY) -->
<context7_usage>
<requirement>ALWAYS use Context7 MCP for documentation lookups before making changes</requirement>
<libraries_requiring_context7>
<library name="tailwindcss">Use for CSS utility classes, responsive design, and component styling</library>
<library name="django">Use for models, views, forms, URL patterns, and Django-specific patterns</library>
<library name="django-cotton">Use for component creation, template organization, and Cotton-specific syntax</library>
<library name="htmx">Use for dynamic updates, form handling, and AJAX interactions</library>
<library name="alpinejs">Use for client-side state management, reactive data, and JavaScript interactions</library>
<library name="django-rest-framework">Use for API design, serializers, viewsets, and DRF patterns</library>
<library name="postgresql">Use for database queries, PostGIS functions, and advanced SQL features</library>
<library name="postgis">Use for geographic data handling and spatial queries</library>
<library name="redis">Use for caching strategies, session management, and performance optimization</library>
</libraries_requiring_context7>
<workflow_steps>
<step order="1">Before editing/creating code: Query Context7 for relevant library documentation</step>
<step order="2">During debugging: Use Context7 to verify syntax, patterns, and best practices</step>
<step order="3">When implementing new features: Reference Context7 for current API and method signatures</step>
<step order="4">For performance issues: Consult Context7 for optimization techniques and patterns</step>
<step order="5">For geographic data handling: Use Context7 for PostGIS functions and best practices</step>
<step order="6">For caching strategies: Refer to Context7 for Redis patterns and best practices</step>
<step order="7">For database queries: Utilize Context7 for PostgreSQL best practices and advanced SQL features</step>
</workflow_steps>
<mandatory_scenarios>
<scenario>Creating new Django models or API endpoints</scenario>
<scenario>Implementing HTMX dynamic functionality</scenario>
<scenario>Writing AlpineJS reactive components</scenario>
<scenario>Designing responsive layouts with Tailwind CSS</scenario>
<scenario>Creating Django-Cotton components</scenario>
<scenario>Debugging CSS, JavaScript, or Django issues</scenario>
<scenario>Implementing caching or database optimizations</scenario>
<scenario>Handling geographic data with PostGIS</scenario>
<scenario>Utilizing Redis for session management</scenario>
<scenario>Implementing real-time features with WebSockets</scenario>
</mandatory_scenarios>
<context7_commands>
<resolve_library>Always call Context7:resolve-library-id first to get correct library ID</resolve_library>
<get_docs>Then use Context7:get-library-docs with appropriate topic parameter</get_docs>
<example_topics>
<topic library="tailwindcss">responsive design, flexbox, grid, animations</topic>
<topic library="django">models, views, forms, admin, signals</topic>
<topic library="django-cotton">components, templates, slots, props</topic>
<topic library="htmx">hx-get, hx-post, hx-swap, hx-trigger, hx-target</topic>
<topic library="alpinejs">x-data, x-show, x-if, x-for, x-model</topic>
<topic library="django-rest-framework">serializers, viewsets, routers, permissions</topic>
<topic library="postgresql">joins, indexes, transactions, window functions</topic>
<topic library="postgis">geospatial queries, distance calculations, spatial indexes</topic>
<topic library="redis">caching strategies, pub/sub, data structures</topic>
</example_topics>
</context7_commands>
</context7_usage>
</thrillwiki_context>

3
.gitignore vendored
View File

@@ -122,4 +122,5 @@ frontend/.env
django-forwardemail/
frontend/
frontend
.snapshots
.snapshots
uv.lock

View File

@@ -10,7 +10,6 @@ class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
("django_cloudflareimages_toolkit", "0001_initial"),
]
operations = [

View File

@@ -226,7 +226,7 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
class ParkListView(HTMXFilterableMixin, ListView):
model = Park
template_name = "parks/park_list.html"
template_name = "parks/enhanced_park_list.html"
context_object_name = "parks"
filter_class = ParkFilter
paginate_by = 20
@@ -242,9 +242,9 @@ class ParkListView(HTMXFilterableMixin, ListView):
self.paginator_class = OptimizedPaginator
def get_template_names(self) -> list[str]:
"""Return park_list.html for HTMX requests"""
"""Return enhanced park list templates for HTMX requests"""
if self.request.htmx:
return ["parks/partials/park_list.html"]
return ["parks/partials/enhanced_park_list.html"]
return [self.template_name]
def get_view_mode(self) -> ViewMode:

View File

@@ -34,10 +34,10 @@ CACHE_MIDDLEWARE_KEY_PREFIX = config(
"CACHE_MIDDLEWARE_KEY_PREFIX", default="thrillwiki"
)
GDAL_LIBRARY_PATH = config(
"GDAL_LIBRARY_PATH", default="/nix/store/c5y314zvvrbr9lx4wh06ibl1b5c07x92-gdal-3.11.0/lib/libgdal.so"
"GDAL_LIBRARY_PATH", default="/opt/homebrew/opt/gdal/lib/libgdal.dylib"
)
GEOS_LIBRARY_PATH = config(
"GEOS_LIBRARY_PATH", default="/nix/store/r5sgxqxrwfvms97v4v239qbivwsmdfjf-geos-3.13.1/lib/libgeos_c.so"
"GEOS_LIBRARY_PATH", default="/opt/homebrew/opt/geos/lib/libgeos_c.dylib"
)
# Build paths inside the project like this: BASE_DIR / 'subdir'.

View File

@@ -22,8 +22,6 @@ CSRF_TRUSTED_ORIGINS = [
"https://beta.thrillwiki.com",
]
GDAL_LIBRARY_PATH = "/nix/store/c5y314zvvrbr9lx4wh06ibl1b5c07x92-gdal-3.11.0/lib/libgdal.so"
GEOS_LIBRARY_PATH = "/nix/store/r5sgxqxrwfvms97v4v239qbivwsmdfjf-geos-3.13.1/lib/libgeos_c.so"
# Local cache configuration
LOC_MEM_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"

View File

@@ -2,38 +2,68 @@
Database configuration for thrillwiki project.
"""
import environ
from pathlib import Path
from decouple import config
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent.parent
env = environ.Env(
DATABASE_URL=(
str,
"postgis://thrillwiki_user:thrillwiki@localhost:5432/thrillwiki_test_db",
),
GDAL_LIBRARY_PATH=(str, "/nix/store/c5y314zvvrbr9lx4wh06ibl1b5c07x92-gdal-3.11.0/lib/libgdal.so"),
GEOS_LIBRARY_PATH=(str, "/nix/store/r5sgxqxrwfvms97v4v239qbivwsmdfjf-geos-3.13.1/lib/libgeos_c.so"),
CACHE_URL=(str, "locmemcache://"),
CACHE_MIDDLEWARE_SECONDS=(int, 300),
CACHE_MIDDLEWARE_KEY_PREFIX=(str, "thrillwiki"),
)
DATABASE_URL=config("DATABASE_URL")
GDAL_LIBRARY_PATH=config("GDAL_LIBRARY_PATH")
GEOS_LIBRARY_PATH=config("GEOS_LIBRARY_PATH")
CACHE_URL=config("CACHE_URL")
CACHE_MIDDLEWARE_SECONDS=config("CACHE_MIDDLEWARE_SECONDS")
CACHE_MIDDLEWARE_KEY_PREFIX=config("CACHE_MIDDLEWARE_KEY_PREFIX")
# Database configuration
db_config = env.db("DATABASE_URL")
db_url = config("DATABASE_URL")
# Parse the database URL and create proper configuration dictionary
def parse_db_url(url):
# Simple parsing for PostgreSQL URLs
if url.startswith('postgres://') or url.startswith('postgis://'):
# Format: postgres://username:password@host:port/database
# Remove the protocol part
if url.startswith('postgis://'):
url = url.replace('postgis://', '')
elif url.startswith('postgres://'):
url = url.replace('postgres://', '')
# Split the URL into parts
auth_part, rest = url.split('@', 1)
host_port, database = rest.split('/', 1)
username, password = auth_part.split(':', 1) if ':' in auth_part else (auth_part, '')
host, port = host_port.split(':', 1) if ':' in host_port else (host_port, '5432')
return {
'ENGINE': 'django.contrib.gis.db.backends.postgis',
'NAME': database,
'USER': username,
'PASSWORD': password,
'HOST': host,
'PORT': port,
}
# Add support for other database types if needed
else:
raise ValueError(f"Unsupported database URL format: {url}")
# Switch back to PostgreSQL - GeoDjango issues resolved separately
DATABASES = {
"default": db_config,
"default": parse_db_url(db_url),
}
# GeoDjango Settings - Environment specific with fallbacks
GDAL_LIBRARY_PATH = env("GDAL_LIBRARY_PATH")
GEOS_LIBRARY_PATH = env("GEOS_LIBRARY_PATH")
GDAL_LIBRARY_PATH = config("GDAL_LIBRARY_PATH")
GEOS_LIBRARY_PATH = config("GEOS_LIBRARY_PATH")
# Cache settings
CACHES = {"default": env.cache("CACHE_URL")}
# Cache settings - Use simple local memory cache for development
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'unique-snowflake',
}
}
CACHE_MIDDLEWARE_SECONDS = env.int("CACHE_MIDDLEWARE_SECONDS")
CACHE_MIDDLEWARE_KEY_PREFIX = env("CACHE_MIDDLEWARE_KEY_PREFIX")
CACHE_MIDDLEWARE_SECONDS = config("CACHE_MIDDLEWARE_SECONDS")
CACHE_MIDDLEWARE_KEY_PREFIX = config("CACHE_MIDDLEWARE_KEY_PREFIX")

View File

@@ -217,7 +217,7 @@
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
-webkit-text-size-adjust: none;
tab-size: 4;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
font-feature-settings: var(--default-font-feature-settings, normal);
@@ -2466,15 +2466,6 @@
-webkit-user-select: none;
user-select: none;
}
.\[coverage\:report\] {
coverage: report;
}
.\[coverage\:run\] {
coverage: run;
}
.\[tool\:pytest\] {
tool: pytest;
}
.group-hover\:translate-x-1 {
&:is(:where(.group):hover *) {
@media (hover: hover) {

View File

@@ -66,7 +66,7 @@
<script src="{% static 'js/theme.js' %}?v={{ version|default:'1.0' }}"></script>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.6" integrity="sha384-FYYOtJ1BoGZ1EZbdLzmaydhwRKh5zxCwWA0jzNEzSJYTzFxN2wjCjOj3gLyQYZGC" crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx.org@1.9.6" integrity="sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni" crossorigin="anonymous"></script>
<!-- Alpine.js Components (must load before Alpine.js) -->
<script src="{% static 'js/alpine-components.js' %}?v={{ version|default:'1.0' }}"></script>

View File

@@ -0,0 +1,438 @@
{% comment %}
Advanced Filters Component - Django Cotton Version
Comprehensive filtering panel with collapsible sections, range inputs, and advanced options.
Provides extensive filtering capabilities for park listings with intuitive UX.
Usage Examples:
<c-advanced_filters
filter_counts=filter_counts
current_filters=request.GET
/>
<c-advanced_filters
filter_counts=filter_counts
current_filters=request.GET
show_advanced=True
class="custom-class"
/>
Parameters:
- filter_counts: Dictionary with filter statistics (required)
- current_filters: Current filter values from request.GET (required)
- show_advanced: Whether to show advanced filters by default (default: False)
- class: Additional CSS classes (optional)
Features:
- Collapsible filter sections
- Range sliders for numeric filters
- Multi-select options
- Location-based filtering
- Date range pickers
- Real-time filter counts
- Mobile-optimized interface
- HTMX integration for seamless updates
{% endcomment %}
<c-vars
filter_counts
current_filters
show_advanced="false"
class=""
/>
<!-- Collapsible Sidebar Filter Panel -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 {{ class }}"
x-data="advancedFilters()"
x-init="init()">
<!-- Filter Header with Toggle -->
<div class="flex items-center justify-between p-4 sm:p-6 border-b border-gray-200 dark:border-gray-700">
<button type="button"
class="flex items-center gap-3 text-left w-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-lg"
@click="toggleFilters()"
:aria-expanded="filtersOpen">
<div class="flex items-center gap-3 flex-1">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Filters</h2>
<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/30 dark:text-blue-300"
x-text="activeFilterCount + ' active'"></span>
</div>
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400 transition-transform duration-200"
:class="{ 'rotate-180': filtersOpen }"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<button type="button"
class="ml-4 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium focus:outline-none focus:underline"
@click="clearAllFilters()">
Clear All
</button>
</div>
<!-- Collapsible Filter Content -->
<div x-show="filtersOpen"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 max-h-0"
x-transition:enter-end="opacity-100 max-h-screen"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 max-h-screen"
x-transition:leave-end="opacity-0 max-h-0"
class="overflow-hidden"
style="display: none;">
<div class="p-4 sm:p-6 space-y-6">
<!-- Status Filter Section -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Operating Status
</h3>
<select name="status"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only']"
hx-push-url="true"
hx-indicator="#search-spinner">
<option value="">All Statuses</option>
<option value="OPERATING" {% if current_filters.status == 'OPERATING' %}selected{% endif %}>
🟢 Operating ({{ filter_counts.status_counts.OPERATING|default:0 }})
</option>
<option value="CLOSED_TEMP" {% if current_filters.status == 'CLOSED_TEMP' %}selected{% endif %}>
🟡 Temporarily Closed ({{ filter_counts.status_counts.CLOSED_TEMP|default:0 }})
</option>
<option value="CLOSED_PERM" {% if current_filters.status == 'CLOSED_PERM' %}selected{% endif %}>
🔴 Permanently Closed ({{ filter_counts.status_counts.CLOSED_PERM|default:0 }})
</option>
<option value="UNDER_CONSTRUCTION" {% if current_filters.status == 'UNDER_CONSTRUCTION' %}selected{% endif %}>
🚧 Under Construction ({{ filter_counts.status_counts.UNDER_CONSTRUCTION|default:0 }})
</option>
</select>
</div>
<!-- Park Type Filter Section -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
Park Type
</h3>
<select name="park_type"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only']"
hx-push-url="true"
hx-indicator="#search-spinner">
<option value="">All Types</option>
<option value="disney" {% if current_filters.park_type == 'disney' %}selected{% endif %}>
🏰 Disney Parks
</option>
<option value="universal" {% if current_filters.park_type == 'universal' %}selected{% endif %}>
🎬 Universal Parks
</option>
<option value="six_flags" {% if current_filters.park_type == 'six_flags' %}selected{% endif %}>
🎢 Six Flags
</option>
<option value="cedar_fair" {% if current_filters.park_type == 'cedar_fair' %}selected{% endif %}>
🌲 Cedar Fair
</option>
<option value="independent" {% if current_filters.park_type == 'independent' %}selected{% endif %}>
⭐ Independent Parks
</option>
</select>
</div>
<!-- Operator Filter Section -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
Operator
</h3>
<select name="operator"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only']"
hx-push-url="true"
hx-indicator="#search-spinner">
<option value="">All Operators</option>
{% for operator in filter_counts.top_operators %}
<option value="{{ operator.operator__id }}"
{% if current_filters.operator == operator.operator__id|stringformat:"s" %}selected{% endif %}>
{{ operator.operator__name }} ({{ operator.park_count }})
</option>
{% endfor %}
</select>
</div>
<!-- Rating Filter Section -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
</svg>
Minimum Rating
</h3>
<select name="min_rating"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='has_coasters'], [name='big_parks_only']"
hx-push-url="true"
hx-indicator="#search-spinner">
<option value="">Any Rating</option>
<option value="3" {% if current_filters.min_rating == '3' %}selected{% endif %}>
⭐⭐⭐ 3+ Stars
</option>
<option value="4" {% if current_filters.min_rating == '4' %}selected{% endif %}>
⭐⭐⭐⭐ 4+ Stars
</option>
<option value="4.5" {% if current_filters.min_rating == '4.5' %}selected{% endif %}>
⭐⭐⭐⭐⭐ 4.5+ Stars
</option>
</select>
</div>
<!-- Advanced Filters Toggle -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<button type="button"
class="flex items-center justify-between w-full text-sm font-medium text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
@click="showAdvanced = !showAdvanced"
:aria-expanded="showAdvanced">
<span class="flex items-center gap-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="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"/>
</svg>
Advanced Filters
</span>
<svg class="w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-180': showAdvanced }"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
</div>
<!-- Advanced Filters Content -->
<div x-show="showAdvanced"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
class="space-y-6"
style="display: none;">
<!-- Ride Count Range -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-gray-900 dark:text-white">Number of Rides</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Minimum</label>
<input type="number"
name="min_rides"
value="{{ current_filters.min_rides }}"
placeholder="0"
min="0"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-trigger="change delay:500ms"
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='max_rides']"
hx-push-url="true"
hx-indicator="#search-spinner">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Maximum</label>
<input type="number"
name="max_rides"
value="{{ current_filters.max_rides }}"
placeholder="∞"
min="0"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-trigger="change delay:500ms"
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='min_rides']"
hx-push-url="true"
hx-indicator="#search-spinner">
</div>
</div>
</div>
<!-- Coaster Count Range -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-gray-900 dark:text-white">Number of Roller Coasters</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Minimum</label>
<input type="number"
name="min_coasters"
value="{{ current_filters.min_coasters }}"
placeholder="0"
min="0"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-trigger="change delay:500ms"
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='max_coasters']"
hx-push-url="true"
hx-indicator="#search-spinner">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Maximum</label>
<input type="number"
name="max_coasters"
value="{{ current_filters.max_coasters }}"
placeholder="∞"
min="0"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-trigger="change delay:500ms"
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='min_coasters']"
hx-push-url="true"
hx-indicator="#search-spinner">
</div>
</div>
</div>
<!-- Location Filters -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-gray-900 dark:text-white">Location</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Country</label>
<input type="text"
name="country_filter"
value="{{ current_filters.country_filter }}"
placeholder="e.g., United States"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-trigger="keyup changed delay:500ms"
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='state_filter']"
hx-push-url="true"
hx-indicator="#search-spinner">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">State/Region</label>
<input type="text"
name="state_filter"
value="{{ current_filters.state_filter }}"
placeholder="e.g., California"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-trigger="keyup changed delay:500ms"
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='country_filter']"
hx-push-url="true"
hx-indicator="#search-spinner">
</div>
</div>
</div>
<!-- Opening Date Range -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-gray-900 dark:text-white">Opening Date</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">From</label>
<input type="date"
name="opening_date_after"
value="{{ current_filters.opening_date_after }}"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-trigger="change delay:500ms"
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='opening_date_before']"
hx-push-url="true"
hx-indicator="#search-spinner">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">To</label>
<input type="date"
name="opening_date_before"
value="{{ current_filters.opening_date_before }}"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-trigger="change delay:500ms"
hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='status'], [name='operator'], [name='park_type'], [name='min_rating'], [name='has_coasters'], [name='big_parks_only'], [name='opening_date_after']"
hx-push-url="true"
hx-indicator="#search-spinner">
</div>
</div>
</div>
</div>
</div>
</div>
<!-- AlpineJS Component Script -->
<script>
function advancedFilters() {
return {
showAdvanced: {{ show_advanced|yesno:"true,false" }},
filtersOpen: window.innerWidth >= 1024, // Open by default on desktop, closed on mobile
activeFilterCount: 0,
init() {
this.updateActiveFilterCount();
// Handle responsive behavior
this.handleResize();
window.addEventListener('resize', () => this.handleResize());
},
handleResize() {
// Auto-open filters on desktop, keep user preference on mobile
if (window.innerWidth >= 1024) {
this.filtersOpen = true;
}
},
toggleFilters() {
this.filtersOpen = !this.filtersOpen;
},
updateActiveFilterCount() {
// Count active filters from current_filters
let count = 0;
try {
// Convert Django QueryDict to JavaScript object safely
const filters = {
{% for key, value in current_filters.items %}
{% if key != 'page' and key != 'view_mode' and value %}
"{{ key|escapejs }}": "{{ value|escapejs }}",
{% endif %}
{% endfor %}
};
count = Object.keys(filters).length;
} catch (error) {
console.warn('Error counting active filters:', error);
count = 0;
}
this.activeFilterCount = count;
},
clearAllFilters() {
// Navigate to clean URL
window.location.href = '{% url "parks:park_list" %}';
}
}
}
</script>

View File

@@ -0,0 +1,381 @@
{% comment %}
Enhanced Park Card Component - Django Cotton Version
A modern, responsive park card component with improved layouts for both grid and list views.
Features enhanced visual design, better mobile optimization, and rich interactive elements.
Usage Examples:
Grid View:
<c-enhanced_park_card
park=park
view_mode="grid"
/>
List View:
<c-enhanced_park_card
park=park
view_mode="list"
/>
Compact Grid View:
<c-enhanced_park_card
park=park
view_mode="grid"
size="compact"
/>
Parameters:
- park: Park object (required)
- view_mode: "list" or "grid" (default: "grid")
- size: "normal" or "compact" (default: "normal")
- show_stats: Whether to show ride/coaster stats (default: True)
- show_rating: Whether to show rating (default: True)
- class: Additional CSS classes (optional)
Features:
- Modern card design with enhanced visual hierarchy
- Responsive image handling with CloudFlare Images
- Interactive hover effects and animations
- Accessibility improvements with ARIA labels
- Status badges with improved styling
- Rating display with star visualization
- Optimized for both mobile and desktop
- Support for compact grid layout
- Enhanced typography and spacing
{% endcomment %}
<c-vars
park
view_mode="grid"
size="normal"
show_stats="true"
show_rating="true"
class=""
/>
{% if park %}
{% if view_mode == 'list' %}
{# Enhanced List View Layout #}
<article class="group bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm border border-gray-200/60 dark:border-gray-700/60 rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 transform hover:scale-[1.01] overflow-hidden {{ class }}"
role="article"
aria-labelledby="park-title-{{ park.id }}"
aria-describedby="park-description-{{ park.id }}">
<div class="p-5 sm:p-7">
<div class="flex flex-col lg:flex-row gap-5 lg:gap-7">
{# Enhanced Image Section for List View #}
<div class="flex-shrink-0 w-full lg:w-64 xl:w-72">
<div class="relative aspect-[16/9] lg:aspect-[4/3] bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 rounded-xl overflow-hidden group-hover:shadow-lg transition-shadow duration-300">
{% if park.card_image.image or park.photos.first.image %}
{% with image=park.card_image.image|default:park.photos.first.image %}
<picture class="w-full h-full">
<source media="(max-width: 1023px)"
srcset="{{ image.public_url }}/w=800,h=450,fit=cover,f=webp 1x, {{ image.public_url }}/w=1600,h=900,fit=cover,f=webp 2x"
type="image/webp">
<source media="(min-width: 1024px)"
srcset="{{ image.public_url }}/w=600,h=450,fit=cover,f=webp 1x, {{ image.public_url }}/w=1200,h=900,fit=cover,f=webp 2x"
type="image/webp">
<img src="{{ image.public_url }}/w=800,h=450,fit=cover"
alt="{{ park.name }} - {% if park.card_image.alt_text %}{{ park.card_image.alt_text }}{% elif park.photos.first.alt_text %}{{ park.photos.first.alt_text }}{% else %}Theme park exterior view{% endif %}"
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
loading="lazy"
decoding="async">
</picture>
{% endwith %}
{% else %}
<div class="flex items-center justify-center h-full text-white/70 bg-gradient-to-br from-gray-400 via-gray-500 to-gray-600 dark:from-gray-600 dark:via-gray-700 dark:to-gray-800">
<div class="text-center p-6">
<svg class="w-12 h-12 mx-auto mb-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
</svg>
<p class="text-sm font-medium opacity-80">No Image Available</p>
</div>
</div>
{% endif %}
{# Enhanced Status Badge #}
<div class="absolute top-3 right-3">
<span class="inline-flex items-center px-3 py-1.5 rounded-full text-xs font-bold border-2 bg-white/95 backdrop-blur-sm shadow-lg
{% if park.status == 'operating' or park.status == 'OPERATING' %}text-green-700 border-green-300 shadow-green-200/50
{% elif park.status == 'closed' or park.status == 'CLOSED_PERM' %}text-red-700 border-red-300 shadow-red-200/50
{% elif park.status == 'closed_temp' or park.status == 'CLOSED_TEMP' %}text-yellow-700 border-yellow-300 shadow-yellow-200/50
{% elif park.status == 'under_construction' or park.status == 'UNDER_CONSTRUCTION' %}text-blue-700 border-blue-300 shadow-blue-200/50
{% else %}text-gray-700 border-gray-300 shadow-gray-200/50{% endif %}"
role="img"
aria-label="Park status: {{ park.get_status_display }}"
title="Park status: {{ park.get_status_display }}">
{{ park.get_status_display }}
</span>
</div>
{# Rating Badge (if enabled) #}
{% if show_rating == "true" and park.average_rating %}
<div class="absolute bottom-3 left-3">
<div class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-black/70 text-white backdrop-blur-sm">
<svg class="w-3 h-3 mr-1 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
</svg>
{{ park.average_rating|floatformat:1 }}
</div>
</div>
{% endif %}
</div>
</div>
{# Enhanced Content Section #}
<div class="flex-1 min-w-0 flex flex-col justify-between">
<div class="space-y-4">
{# Title and Operator #}
<div>
<h3 id="park-title-{{ park.id }}" class="text-xl sm:text-2xl lg:text-3xl font-bold line-clamp-2 leading-tight mb-2">
{% if park.slug %}
<a href="{% url 'parks:park_detail' park.slug %}"
class="bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 bg-clip-text text-transparent hover:from-blue-600 hover:to-purple-600 dark:hover:from-blue-400 dark:hover:to-purple-400 transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-sm"
aria-label="View details for {{ park.name }}">
{{ park.name }}
</a>
{% else %}
<span class="bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 bg-clip-text text-transparent">
{{ park.name }}
</span>
{% endif %}
</h3>
{% if park.operator %}
<div class="flex items-center text-base font-medium text-gray-600 dark:text-gray-400">
<svg class="w-4 h-4 mr-2 flex-shrink-0 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
<span class="truncate">{{ park.operator.name }}</span>
</div>
{% endif %}
</div>
{# Description #}
{% if park.description %}
<p id="park-description-{{ park.id }}" class="text-gray-600 dark:text-gray-400 line-clamp-3 leading-relaxed text-base">
{{ park.description|truncatewords:40 }}
</p>
{% endif %}
{# Location Info #}
{% if park.location %}
<div class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<svg class="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>
{% if park.location.city %}{{ park.location.city }}{% endif %}{% if park.location.city and park.location.state %}, {% endif %}{% if park.location.state %}{{ park.location.state }}{% endif %}{% if park.location.country and park.location.country != "United States" %}, {{ park.location.country }}{% endif %}
</span>
</div>
{% endif %}
</div>
{# Stats Section #}
{% if show_stats == "true" and park.ride_count or park.coaster_count %}
<div class="flex items-center justify-between pt-5 border-t border-gray-200/60 dark:border-gray-600/60 mt-5">
<div class="flex items-center space-x-6">
{% if park.ride_count %}
<div class="flex items-center space-x-2 px-4 py-2 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 rounded-xl border border-blue-200/50 dark:border-blue-800/50"
role="img"
aria-label="{{ park.ride_count }} ride{{ park.ride_count|pluralize }} available"
title="{{ park.ride_count }} ride{{ park.ride_count|pluralize }}">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" 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>
<span class="font-bold text-blue-700 dark:text-blue-300 text-lg">{{ park.ride_count }}</span>
<span class="text-blue-600 dark:text-blue-400 font-medium">rides</span>
</div>
{% endif %}
{% if park.coaster_count %}
<div class="flex items-center space-x-2 px-4 py-2 bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 rounded-xl border border-purple-200/50 dark:border-purple-800/50"
role="img"
aria-label="{{ park.coaster_count }} roller coaster{{ park.coaster_count|pluralize }} available"
title="{{ park.coaster_count }} roller coaster{{ park.coaster_count|pluralize }}">
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400 flex-shrink-0" 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>
<span class="font-bold text-purple-700 dark:text-purple-300 text-lg">{{ park.coaster_count }}</span>
<span class="text-purple-600 dark:text-purple-400 font-medium">coasters</span>
</div>
{% endif %}
</div>
{# View Details Arrow #}
<div class="text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-300">
<svg class="w-6 h-6 transform group-hover:translate-x-2 transition-transform duration-300" 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>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</article>
{% else %}
{# Enhanced Grid View Layout #}
<article class="group bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm border border-gray-200/60 dark:border-gray-700/60 rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 transform hover:scale-105 hover:-rotate-1 overflow-hidden {{ class }}"
role="article"
aria-labelledby="park-title-grid-{{ park.id }}"
aria-describedby="park-description-grid-{{ park.id }}">
{# Enhanced Image Section for Grid View #}
<div class="relative {% if size == 'compact' %}aspect-[4/3]{% else %}aspect-[4/3]{% endif %} bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 overflow-hidden">
{% if park.card_image.image or park.photos.first.image %}
{% with image=park.card_image.image|default:park.photos.first.image %}
<picture class="w-full h-full">
{% if size == "compact" %}
<source media="(max-width: 767px)"
srcset="{{ image.public_url }}/w=400,h=300,fit=cover,f=webp 1x, {{ image.public_url }}/w=800,h=600,fit=cover,f=webp 2x"
type="image/webp">
<source media="(min-width: 768px)"
srcset="{{ image.public_url }}/w=300,h=225,fit=cover,f=webp 1x, {{ image.public_url }}/w=600,h=450,fit=cover,f=webp 2x"
type="image/webp">
{% else %}
<source media="(max-width: 767px)"
srcset="{{ image.public_url }}/w=600,h=450,fit=cover,f=webp 1x, {{ image.public_url }}/w=1200,h=900,fit=cover,f=webp 2x"
type="image/webp">
<source media="(min-width: 768px)"
srcset="{{ image.public_url }}/w=400,h=300,fit=cover,f=webp 1x, {{ image.public_url }}/w=800,h=600,fit=cover,f=webp 2x"
type="image/webp">
{% endif %}
<img src="{{ image.public_url }}/w=600,h=450,fit=cover"
alt="{{ park.name }} - {% if park.card_image.alt_text %}{{ park.card_image.alt_text }}{% elif park.photos.first.alt_text %}{{ park.photos.first.alt_text }}{% else %}Theme park exterior view{% endif %}"
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
loading="lazy"
decoding="async">
</picture>
{# Image Overlay Effects #}
<div class="absolute inset-0 bg-gradient-to-t from-black/30 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
{% endwith %}
{% else %}
<div class="flex flex-col items-center justify-center h-full text-white/70 bg-gradient-to-br from-gray-400 via-gray-500 to-gray-600 dark:from-gray-600 dark:via-gray-700 dark:to-gray-800">
<div class="p-6 text-center">
<svg class="w-16 h-16 mx-auto mb-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
</svg>
<p class="text-sm font-medium opacity-80">No Image Available</p>
<p class="text-xs opacity-60 mt-1">{{ park.name }}</p>
</div>
</div>
{% endif %}
{# Enhanced Status Badge #}
<div class="absolute top-3 right-3">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold border-2 bg-white/95 backdrop-blur-sm shadow-lg
{% if park.status == 'operating' or park.status == 'OPERATING' %}text-green-700 border-green-300 shadow-green-200/50
{% elif park.status == 'closed' or park.status == 'CLOSED_PERM' %}text-red-700 border-red-300 shadow-red-200/50
{% elif park.status == 'closed_temp' or park.status == 'CLOSED_TEMP' %}text-yellow-700 border-yellow-300 shadow-yellow-200/50
{% elif park.status == 'under_construction' or park.status == 'UNDER_CONSTRUCTION' %}text-blue-700 border-blue-300 shadow-blue-200/50
{% else %}text-gray-700 border-gray-300 shadow-gray-200/50{% endif %}"
role="img"
aria-label="Park status: {{ park.get_status_display }}"
title="Park status: {{ park.get_status_display }}">
{{ park.get_status_display }}
</span>
</div>
{# Rating Badge (if enabled) #}
{% if show_rating == "true" and park.average_rating %}
<div class="absolute bottom-3 left-3">
<div class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-black/70 text-white backdrop-blur-sm">
<svg class="w-3 h-3 mr-1 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
</svg>
{{ park.average_rating|floatformat:1 }}
</div>
</div>
{% endif %}
</div>
{# Enhanced Content Area #}
<div class="p-5 {% if size == 'compact' %}sm:p-4{% else %}sm:p-6{% endif %}">
<div class="{% if size == 'compact' %}mb-3{% else %}mb-4{% endif %}">
{# Title #}
<h3 id="park-title-grid-{{ park.id }}" class="{% if size == 'compact' %}text-lg{% else %}text-xl{% endif %} font-bold line-clamp-2 mb-2 leading-tight">
{% if park.slug %}
<a href="{% url 'parks:park_detail' park.slug %}"
class="text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-sm"
aria-label="View details for {{ park.name }}">
{{ park.name }}
</a>
{% else %}
<span class="text-gray-900 dark:text-white">
{{ park.name }}
</span>
{% endif %}
</h3>
{# Operator #}
{% if park.operator %}
<div class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3 truncate flex items-center">
<svg class="w-3 h-3 mr-1.5 flex-shrink-0 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
<span class="truncate">{{ park.operator.name }}</span>
</div>
{% endif %}
</div>
{# Description #}
{% if park.description and size != "compact" %}
<p id="park-description-grid-{{ park.id }}" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-3 mb-4 leading-relaxed">
{{ park.description|truncatewords:20 }}
</p>
{% endif %}
{# Stats Footer #}
{% if show_stats == "true" and park.ride_count or park.coaster_count %}
<div class="flex items-center justify-between pt-4 border-t border-gray-200/60 dark:border-gray-600/60">
<div class="flex items-center space-x-4 text-sm">
{% if park.ride_count %}
<div class="flex items-center space-x-1.5 text-blue-600 dark:text-blue-400"
role="img"
aria-label="{{ park.ride_count }} ride{{ park.ride_count|pluralize }} available"
title="{{ park.ride_count }} ride{{ park.ride_count|pluralize }}">
<svg class="w-4 h-4 flex-shrink-0" 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>
<span class="font-bold">{{ park.ride_count }}</span>
{% if size != "compact" %}<span class="text-xs opacity-75">rides</span>{% endif %}
</div>
{% endif %}
{% if park.coaster_count %}
<div class="flex items-center space-x-1.5 text-purple-600 dark:text-purple-400"
role="img"
aria-label="{{ park.coaster_count }} roller coaster{{ park.coaster_count|pluralize }} available"
title="{{ park.coaster_count }} roller coaster{{ park.coaster_count|pluralize }}">
<svg class="w-4 h-4 flex-shrink-0" 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>
<span class="font-bold">{{ park.coaster_count }}</span>
{% if size != "compact" %}<span class="text-xs opacity-75">coasters</span>{% endif %}
</div>
{% endif %}
</div>
{# View Details Arrow #}
<div class="text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-300">
<svg class="w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-300" 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>
</div>
</div>
{% else %}
{# Show arrow even when no stats for consistent layout #}
<div class="flex justify-end pt-4 border-t border-gray-200/60 dark:border-gray-600/60">
<div class="text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-300">
<svg class="w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-300" 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>
</div>
</div>
{% endif %}
</div>
</article>
{% endif %}
{% endif %}

View File

@@ -54,13 +54,19 @@ Features:
this.open = false;
this.suggestions = [];
this.selectedIndex = -1;
htmx.trigger(this.$refs.searchInput, 'keyup');
// Trigger search update if HTMX is available
if (typeof htmx !== 'undefined' && this.$refs.searchInput) {
htmx.trigger(this.$refs.searchInput, 'keyup');
}
},
selectSuggestion(suggestion) {
this.search = suggestion.name || suggestion;
this.open = false;
this.selectedIndex = -1;
htmx.trigger(this.$refs.searchInput, 'keyup');
// Trigger search update if HTMX is available
if (typeof htmx !== 'undefined' && this.$refs.searchInput) {
htmx.trigger(this.$refs.searchInput, 'keyup');
}
},
handleKeydown(event) {
if (!this.open) return;
@@ -260,4 +266,4 @@ Features:
`${suggestions.length} suggestion${suggestions.length !== 1 ? 's' : ''} available. Use arrow keys to navigate.` :
(search.length >= 2 && !loading && suggestions.length === 0 ? 'No suggestions found.' : '')">
</div>
</div>
</div>

View File

@@ -0,0 +1,495 @@
{% extends "base/base.html" %}
{% load static %}
{% load cotton %}
{% block title %}Parks - Enhanced Experience{% endblock %}
{% block content %}
{# Skip Navigation Links for Accessibility #}
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 z-50 px-4 py-2 bg-blue-600 text-white font-semibold rounded-lg shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200">
Skip to main content
</a>
<a href="#search-form" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-32 z-50 px-4 py-2 bg-blue-600 text-white font-semibold rounded-lg shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200">
Skip to search
</a>
{# Enhanced Container with Better Layout #}
<div class="min-h-screen bg-transparent"
x-data="enhancedParkListState()"
x-init="init()">
<div class="container mx-auto px-3 sm:px-4 lg:px-6 py-6 sm:py-8">
{# Enhanced Header Section #}
<header class="mb-8 sm:mb-12" aria-labelledby="page-title">
<div class="text-center mb-8">
<h1 id="page-title" class="text-3xl sm:text-4xl lg:text-5xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent leading-tight mb-4">
Discover Amazing Theme Parks
</h1>
<p class="text-lg sm:text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto leading-relaxed" id="page-description">
Explore the world's most thrilling theme parks with comprehensive information, reviews, and insider details
</p>
</div>
{# Enhanced Statistics Cards #}
<section aria-labelledby="park-statistics" class="grid grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 mb-8">
<h2 id="park-statistics" class="sr-only">Park Statistics Overview</h2>
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105"
role="img"
aria-labelledby="total-parks-stat"
tabindex="0">
<div class="flex items-center justify-between">
<div>
<div id="total-parks-stat" class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"
aria-label="{{ filter_counts.total_parks|default:0 }} total parks in database">
{{ filter_counts.total_parks|default:0 }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 font-medium">Total Parks</div>
</div>
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
</div>
</div>
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105"
role="img"
aria-labelledby="operating-parks-stat"
tabindex="0">
<div class="flex items-center justify-between">
<div>
<div id="operating-parks-stat" class="text-2xl sm:text-3xl font-bold text-green-600 dark:text-green-400"
aria-label="{{ filter_counts.operating_parks|default:0 }} currently operating parks">
{{ filter_counts.operating_parks|default:0 }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 font-medium">Operating</div>
</div>
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105"
role="img"
aria-labelledby="coaster-parks-stat"
tabindex="0">
<div class="flex items-center justify-between">
<div>
<div id="coaster-parks-stat" class="text-2xl sm:text-3xl font-bold text-purple-600 dark:text-purple-400"
aria-label="{{ filter_counts.parks_with_coasters|default:0 }} parks with roller coasters">
{{ filter_counts.parks_with_coasters|default:0 }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 font-medium">With Coasters</div>
</div>
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105"
role="img"
aria-labelledby="countries-stat"
tabindex="0">
<div class="flex items-center justify-between">
<div>
<div id="countries-stat" class="text-2xl sm:text-3xl font-bold text-orange-600 dark:text-orange-400"
aria-label="{{ filter_counts.countries_count|default:0 }} countries represented">
{{ filter_counts.countries_count|default:0 }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 font-medium">Countries</div>
</div>
<div class="w-12 h-12 bg-orange-100 dark:bg-orange-900/30 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
</section>
</header>
{# Main Content Layout #}
<div class="flex flex-col xl:flex-row gap-8">
{# Sidebar with Advanced Filters #}
<aside class="xl:w-80 flex-shrink-0" aria-label="Park filters">
<div class="sticky top-6">
{# Enhanced Search Section #}
<section class="mb-6" aria-labelledby="search-section" role="search">
<h2 id="search-section" class="sr-only">Search Parks</h2>
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-2xl p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-lg">
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Search Parks</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Find parks by name, location, or features</p>
</div>
<div id="search-form">
<c-enhanced_search
placeholder="Search parks by name, location, or features..."
current_value="{{ search_query }}"
autocomplete_url="{% url 'parks:park_autocomplete' %}"
class="w-full"
/>
</div>
</div>
</section>
{# Advanced Filters Component #}
<c-advanced_filters
filter_counts=filter_counts
current_filters=request.GET
show_advanced="false"
/>
</div>
</aside>
{# Main Content Area #}
<main class="flex-1 min-w-0" id="main-content" aria-label="Park listings">
{# Controls Bar #}
<div class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-lg mb-6">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
{# Results Stats #}
<div class="flex-1">
<c-result_stats
total_results="{{ total_results }}"
page_obj="{{ page_obj }}"
search_query="{{ search_query }}"
is_search="{{ is_search }}"
filter_count="{{ filter_count }}"
/>
</div>
{# View Controls #}
<div class="flex items-center gap-4">
{# Sort Controls #}
<c-sort_controls
current_sort="{{ current_ordering }}"
class="flex-shrink-0"
/>
{# View Toggle #}
<c-view_toggle
current_view="{{ view_mode }}"
class="flex-shrink-0"
/>
</div>
</div>
{# Active Filter Chips #}
{% if active_filters %}
<div class="border-t border-gray-200/50 dark:border-gray-700/50 pt-4 mt-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
Active Filters ({{ filter_count }})
</h3>
<button
type="button"
@click="clearAllFilters()"
class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium focus:outline-none focus:underline transition-colors duration-200"
>
Clear All
</button>
</div>
<c-filter_chips
filters=active_filters
base_url="{% url 'parks:park_list' %}"
class="flex-wrap gap-2"
/>
</div>
{% endif %}
</div>
{# Loading Overlay #}
<div id="loading-overlay" class="htmx-indicator fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-2xl max-w-sm w-full mx-4">
<div class="flex flex-col items-center space-y-4 text-center">
<div class="relative">
<div class="w-16 h-16 border-4 border-blue-200 dark:border-blue-800 rounded-full animate-spin border-t-blue-600 dark:border-t-blue-400"></div>
<div class="absolute inset-0 flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
</div>
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">Loading Parks...</div>
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">Please wait while we fetch the latest data</div>
</div>
</div>
</div>
</div>
{# Park Results Container #}
<div id="park-results"
hx-indicator="#loading-overlay"
class="min-h-[400px]">
{% include "parks/partials/enhanced_park_list.html" %}
</div>
</main>
</div>
</div>
</div>
<!-- Enhanced AlpineJS State Management -->
<script>
function enhancedParkListState() {
return {
viewMode: '{{ view_mode }}',
searchQuery: '{{ search_query }}',
isLoading: false,
error: null,
filterPanelOpen: false,
init() {
// Handle responsive behavior
this.handleResize();
window.addEventListener('resize', this.debounce(() => this.handleResize(), 250));
// Enhanced HTMX event handling
document.addEventListener('htmx:beforeRequest', (event) => {
this.setLoading(true);
this.error = null;
// Add loading class to target element
if (event.detail.target) {
event.detail.target.classList.add('opacity-75', 'pointer-events-none');
}
});
document.addEventListener('htmx:afterRequest', (event) => {
this.setLoading(false);
// Remove loading class from target element
if (event.detail.target) {
event.detail.target.classList.remove('opacity-75', 'pointer-events-none');
}
// Scroll to top of results on mobile after filter changes
if (window.innerWidth < 768 && event.detail.target?.id === 'park-results') {
this.scrollToResults();
}
// Update URL state
if (event.detail.xhr.responseURL) {
const url = new URL(event.detail.xhr.responseURL);
history.replaceState(null, '', url.pathname + url.search);
}
});
document.addEventListener('htmx:responseError', (event) => {
this.setLoading(false);
this.showError('Failed to load results. Please check your connection and try again.');
// Remove loading class from target element
if (event.detail.target) {
event.detail.target.classList.remove('opacity-75', 'pointer-events-none');
}
});
// Handle mobile viewport changes
this.handleMobileViewport();
// Initialize intersection observer for lazy loading
this.initIntersectionObserver();
},
handleResize() {
// Auto-close filter panel on mobile when resizing to desktop
if (window.innerWidth >= 1280) {
this.filterPanelOpen = false;
}
},
handleMobileViewport() {
// Handle mobile viewport changes for better UX
if ('visualViewport' in window) {
window.visualViewport.addEventListener('resize', () => {
document.documentElement.style.setProperty(
'--viewport-height',
`${window.visualViewport.height}px`
);
});
}
},
initIntersectionObserver() {
// Lazy load images and animations
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-fade-in');
observer.unobserve(entry.target);
}
});
}, {
threshold: 0.1,
rootMargin: '50px'
});
// Observe park cards
document.querySelectorAll('[role="article"]').forEach(card => {
observer.observe(card);
});
},
scrollToResults() {
const resultsElement = document.getElementById('park-results');
if (resultsElement) {
resultsElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
},
setLoading(loading) {
this.isLoading = loading;
// Update document title
if (loading) {
document.title = 'Loading Parks... - ThrillWiki';
} else {
document.title = 'Parks - ThrillWiki';
}
},
showError(message) {
this.error = message;
// Show toast notification
this.showToast(message, 'error');
// Auto-clear error after 5 seconds
setTimeout(() => {
this.error = null;
}, 5000);
},
showToast(message, type = 'info') {
// Create and show toast notification
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 z-50 p-4 rounded-lg shadow-lg max-w-sm transform transition-all duration-300 translate-x-full ${
type === 'error' ? 'bg-red-500 text-white' : 'bg-blue-500 text-white'
}`;
toast.textContent = message;
document.body.appendChild(toast);
// Animate in
setTimeout(() => {
toast.classList.remove('translate-x-full');
}, 100);
// Auto-remove after 5 seconds
setTimeout(() => {
toast.classList.add('translate-x-full');
setTimeout(() => {
document.body.removeChild(toast);
}, 300);
}, 5000);
},
clearAllFilters() {
this.setLoading(true);
window.location.href = '{% url "parks:park_list" %}';
},
toggleFilterPanel() {
this.filterPanelOpen = !this.filterPanelOpen;
},
// Utility function for better performance
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
}
}
</script>
<!-- Custom CSS for animations -->
<style>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.6s ease-out forwards;
}
/* Smooth transitions for HTMX updates */
.htmx-settling {
transition: all 300ms ease-in-out;
}
.htmx-swapping {
opacity: 0;
transform: translateY(-10px);
}
/* Loading states */
.htmx-request .htmx-indicator {
display: flex !important;
}
.htmx-request.htmx-indicator {
display: flex !important;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.5);
}
/* Dark mode scrollbar */
.dark ::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
</style>
{% endblock %}

View File

@@ -0,0 +1,280 @@
{% load cotton %}
{# Enhanced Park List Partial - Used for HTMX updates #}
{% if view_mode == 'list' %}
{# Enhanced List View #}
<div class="space-y-6" role="list" aria-label="Parks in list view">
{% for park in parks %}
<div role="listitem">
<c-enhanced_park_card
park=park
view_mode="list"
show_stats="true"
show_rating="true"
/>
</div>
{% empty %}
{# Enhanced Empty State for List View #}
<div class="text-center py-16">
<div class="max-w-md mx-auto">
<div class="w-24 h-24 mx-auto mb-6 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<svg class="w-12 h-12 text-gray-400 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
{% if is_search %}
No parks found for "{{ search_query }}"
{% else %}
No parks found
{% endif %}
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">
{% if is_search %}
Try adjusting your search terms or removing some filters to see more results.
{% else %}
Try adjusting your filters to see more parks.
{% endif %}
</p>
{% if active_filters %}
<button
type="button"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors duration-200"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#loading-overlay">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Clear All Filters
</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
{# Enhanced Grid View with Responsive Columns #}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 sm:gap-8" role="list" aria-label="Parks in grid view">
{% for park in parks %}
<div role="listitem" class="animate-fade-in" style="animation-delay: {{ forloop.counter0|floatformat:0|add:"00" }}ms;">
<c-enhanced_park_card
park=park
view_mode="grid"
size="normal"
show_stats="true"
show_rating="true"
/>
</div>
{% empty %}
{# Enhanced Empty State for Grid View #}
<div class="col-span-full text-center py-16">
<div class="max-w-md mx-auto">
<div class="w-24 h-24 mx-auto mb-6 bg-gradient-to-br from-blue-100 to-purple-100 dark:from-blue-900/30 dark:to-purple-900/30 rounded-full flex items-center justify-center">
<svg class="w-12 h-12 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
{% if is_search %}
No parks found for "{{ search_query }}"
{% else %}
No parks available
{% endif %}
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6 leading-relaxed">
{% if is_search %}
We couldn't find any parks matching your search. Try different keywords or remove some filters.
{% else %}
No parks match your current filter criteria. Try adjusting your filters to see more results.
{% endif %}
</p>
{# Helpful suggestions #}
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 mb-6 text-left">
<h4 class="font-medium text-blue-900 dark:text-blue-100 mb-2">Try these suggestions:</h4>
<ul class="text-sm text-blue-800 dark:text-blue-200 space-y-1">
<li>• Check your spelling</li>
<li>• Use more general search terms</li>
<li>• Remove some filters to broaden your search</li>
{% if is_search %}
<li>• Search for park operators like "Disney" or "Universal"</li>
{% endif %}
</ul>
</div>
<div class="flex flex-col sm:flex-row gap-3 justify-center">
{% if active_filters %}
<button
type="button"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors duration-200"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#loading-overlay">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Clear All Filters
</button>
{% endif %}
<button
type="button"
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors duration-200"
hx-get="{% url 'parks:park_list' %}?has_coasters=true"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#loading-overlay">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
Show Parks with Coasters
</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{# Enhanced Pagination #}
{% if is_paginated %}
<nav class="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm px-4 py-3 sm:px-6 rounded-2xl mt-8" aria-label="Pagination Navigation">
<div class="flex flex-1 justify-between sm:hidden">
{# Mobile Pagination #}
{% if page_obj.has_previous %}
<button
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors duration-200"
hx-get="?page={{ page_obj.previous_page_number }}&{{ request.GET.urlencode }}"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#loading-overlay">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Previous
</button>
{% else %}
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-400 dark:text-gray-600 bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg cursor-not-allowed">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Previous
</span>
{% endif %}
{% if page_obj.has_next %}
<button
class="relative ml-3 inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors duration-200"
hx-get="?page={{ page_obj.next_page_number }}&{{ request.GET.urlencode }}"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#loading-overlay">
Next
<svg class="w-4 h-4 ml-2" 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>
</button>
{% else %}
<span class="relative ml-3 inline-flex items-center px-4 py-2 text-sm font-medium text-gray-400 dark:text-gray-600 bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg cursor-not-allowed">
Next
<svg class="w-4 h-4 ml-2" 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>
{% endif %}
</div>
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700 dark:text-gray-300">
Showing
<span class="font-medium">{{ page_obj.start_index }}</span>
to
<span class="font-medium">{{ page_obj.end_index }}</span>
of
<span class="font-medium">{{ page_obj.paginator.count }}</span>
results
</p>
</div>
<div>
<nav class="isolate inline-flex -space-x-px rounded-lg shadow-sm" aria-label="Pagination">
{# First Page #}
{% if page_obj.has_previous %}
<button
class="relative inline-flex items-center rounded-l-lg px-2 py-2 text-gray-400 dark:text-gray-500 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 focus:z-20 focus:outline-offset-0 transition-colors duration-200"
hx-get="?page=1&{{ request.GET.urlencode }}"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#loading-overlay"
aria-label="Go to first page">
<span class="sr-only">First</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M15.79 14.77a.75.75 0 01-1.06.02l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 111.04 1.08L11.832 10l3.938 3.71a.75.75 0 01.02 1.06zm-6 0a.75.75 0 01-1.06.02l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 111.04 1.08L5.832 10l3.938 3.71a.75.75 0 01.02 1.06z" clip-rule="evenodd" />
</svg>
</button>
<button
class="relative inline-flex items-center px-2 py-2 text-gray-400 dark:text-gray-500 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 focus:z-20 focus:outline-offset-0 transition-colors duration-200"
hx-get="?page={{ page_obj.previous_page_number }}&{{ request.GET.urlencode }}"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#loading-overlay"
aria-label="Go to previous page">
<span class="sr-only">Previous</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
</svg>
</button>
{% endif %}
{# Page Numbers #}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<span aria-current="page" class="relative z-10 inline-flex items-center bg-blue-600 px-4 py-2 text-sm font-semibold text-white focus:z-20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">{{ num }}</span>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<button
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 focus:z-20 focus:outline-offset-0 transition-colors duration-200"
hx-get="?page={{ num }}&{{ request.GET.urlencode }}"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#loading-overlay"
aria-label="Go to page {{ num }}">{{ num }}</button>
{% elif num == page_obj.number|add:'-4' or num == page_obj.number|add:'4' %}
<span class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 focus:outline-offset-0"></span>
{% endif %}
{% endfor %}
{# Next and Last Page #}
{% if page_obj.has_next %}
<button
class="relative inline-flex items-center px-2 py-2 text-gray-400 dark:text-gray-500 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 focus:z-20 focus:outline-offset-0 transition-colors duration-200"
hx-get="?page={{ page_obj.next_page_number }}&{{ request.GET.urlencode }}"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#loading-overlay"
aria-label="Go to next page">
<span class="sr-only">Next</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
</button>
<button
class="relative inline-flex items-center rounded-r-lg px-2 py-2 text-gray-400 dark:text-gray-500 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 focus:z-20 focus:outline-offset-0 transition-colors duration-200"
hx-get="?page={{ page_obj.paginator.num_pages }}&{{ request.GET.urlencode }}"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#loading-overlay"
aria-label="Go to last page">
<span class="sr-only">Last</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M4.21 14.77a.75.75 0 01.02-1.06L8.168 10 4.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02zm6 0a.75.75 0 01.02-1.06L14.168 10l-3.938-3.71a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
</button>
{% endif %}
</nav>
</div>
</div>
</nav>
{% endif %}