mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 05:51:08 -05:00
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:
@@ -1,4 +1,5 @@
|
|||||||
<thrillwiki_context>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<thrillwiki_context version="1.0">
|
||||||
<!-- Core Project Information -->
|
<!-- Core Project Information -->
|
||||||
<project_overview>
|
<project_overview>
|
||||||
<name>ThrillWiki</name>
|
<name>ThrillWiki</name>
|
||||||
@@ -209,4 +210,56 @@
|
|||||||
<database_management>Always use uv run for Django management commands</database_management>
|
<database_management>Always use uv run for Django management commands</database_management>
|
||||||
<testing>All functionality must work with progressive enhancement</testing>
|
<testing>All functionality must work with progressive enhancement</testing>
|
||||||
</workflow>
|
</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>
|
</thrillwiki_context>
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -123,3 +123,4 @@ django-forwardemail/
|
|||||||
frontend/
|
frontend/
|
||||||
frontend
|
frontend
|
||||||
.snapshots
|
.snapshots
|
||||||
|
uv.lock
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("accounts", "0001_initial"),
|
("accounts", "0001_initial"),
|
||||||
("django_cloudflareimages_toolkit", "0001_initial"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
|
|||||||
|
|
||||||
class ParkListView(HTMXFilterableMixin, ListView):
|
class ParkListView(HTMXFilterableMixin, ListView):
|
||||||
model = Park
|
model = Park
|
||||||
template_name = "parks/park_list.html"
|
template_name = "parks/enhanced_park_list.html"
|
||||||
context_object_name = "parks"
|
context_object_name = "parks"
|
||||||
filter_class = ParkFilter
|
filter_class = ParkFilter
|
||||||
paginate_by = 20
|
paginate_by = 20
|
||||||
@@ -242,9 +242,9 @@ class ParkListView(HTMXFilterableMixin, ListView):
|
|||||||
self.paginator_class = OptimizedPaginator
|
self.paginator_class = OptimizedPaginator
|
||||||
|
|
||||||
def get_template_names(self) -> list[str]:
|
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:
|
if self.request.htmx:
|
||||||
return ["parks/partials/park_list.html"]
|
return ["parks/partials/enhanced_park_list.html"]
|
||||||
return [self.template_name]
|
return [self.template_name]
|
||||||
|
|
||||||
def get_view_mode(self) -> ViewMode:
|
def get_view_mode(self) -> ViewMode:
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ CACHE_MIDDLEWARE_KEY_PREFIX = config(
|
|||||||
"CACHE_MIDDLEWARE_KEY_PREFIX", default="thrillwiki"
|
"CACHE_MIDDLEWARE_KEY_PREFIX", default="thrillwiki"
|
||||||
)
|
)
|
||||||
GDAL_LIBRARY_PATH = config(
|
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 = 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'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ CSRF_TRUSTED_ORIGINS = [
|
|||||||
"https://beta.thrillwiki.com",
|
"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
|
# Local cache configuration
|
||||||
LOC_MEM_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
LOC_MEM_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
||||||
|
|||||||
@@ -2,38 +2,68 @@
|
|||||||
Database configuration for thrillwiki project.
|
Database configuration for thrillwiki project.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import environ
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from decouple import config
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
env = environ.Env(
|
DATABASE_URL=config("DATABASE_URL")
|
||||||
DATABASE_URL=(
|
GDAL_LIBRARY_PATH=config("GDAL_LIBRARY_PATH")
|
||||||
str,
|
GEOS_LIBRARY_PATH=config("GEOS_LIBRARY_PATH")
|
||||||
"postgis://thrillwiki_user:thrillwiki@localhost:5432/thrillwiki_test_db",
|
CACHE_URL=config("CACHE_URL")
|
||||||
),
|
CACHE_MIDDLEWARE_SECONDS=config("CACHE_MIDDLEWARE_SECONDS")
|
||||||
GDAL_LIBRARY_PATH=(str, "/nix/store/c5y314zvvrbr9lx4wh06ibl1b5c07x92-gdal-3.11.0/lib/libgdal.so"),
|
CACHE_MIDDLEWARE_KEY_PREFIX=config("CACHE_MIDDLEWARE_KEY_PREFIX")
|
||||||
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 configuration
|
# 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
|
# Switch back to PostgreSQL - GeoDjango issues resolved separately
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": db_config,
|
"default": parse_db_url(db_url),
|
||||||
}
|
}
|
||||||
|
|
||||||
# GeoDjango Settings - Environment specific with fallbacks
|
# GeoDjango Settings - Environment specific with fallbacks
|
||||||
GDAL_LIBRARY_PATH = env("GDAL_LIBRARY_PATH")
|
GDAL_LIBRARY_PATH = config("GDAL_LIBRARY_PATH")
|
||||||
GEOS_LIBRARY_PATH = env("GEOS_LIBRARY_PATH")
|
GEOS_LIBRARY_PATH = config("GEOS_LIBRARY_PATH")
|
||||||
|
|
||||||
# Cache settings
|
# Cache settings - Use simple local memory cache for development
|
||||||
CACHES = {"default": env.cache("CACHE_URL")}
|
CACHES = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||||
|
'LOCATION': 'unique-snowflake',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CACHE_MIDDLEWARE_SECONDS = env.int("CACHE_MIDDLEWARE_SECONDS")
|
CACHE_MIDDLEWARE_SECONDS = config("CACHE_MIDDLEWARE_SECONDS")
|
||||||
CACHE_MIDDLEWARE_KEY_PREFIX = env("CACHE_MIDDLEWARE_KEY_PREFIX")
|
CACHE_MIDDLEWARE_KEY_PREFIX = config("CACHE_MIDDLEWARE_KEY_PREFIX")
|
||||||
|
|||||||
@@ -217,7 +217,7 @@
|
|||||||
}
|
}
|
||||||
html, :host {
|
html, :host {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: none;
|
||||||
tab-size: 4;
|
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-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);
|
font-feature-settings: var(--default-font-feature-settings, normal);
|
||||||
@@ -2466,15 +2466,6 @@
|
|||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.\[coverage\:report\] {
|
|
||||||
coverage: report;
|
|
||||||
}
|
|
||||||
.\[coverage\:run\] {
|
|
||||||
coverage: run;
|
|
||||||
}
|
|
||||||
.\[tool\:pytest\] {
|
|
||||||
tool: pytest;
|
|
||||||
}
|
|
||||||
.group-hover\:translate-x-1 {
|
.group-hover\:translate-x-1 {
|
||||||
&:is(:where(.group):hover *) {
|
&:is(:where(.group):hover *) {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
<script src="{% static 'js/theme.js' %}?v={{ version|default:'1.0' }}"></script>
|
<script src="{% static 'js/theme.js' %}?v={{ version|default:'1.0' }}"></script>
|
||||||
|
|
||||||
<!-- HTMX -->
|
<!-- 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) -->
|
<!-- Alpine.js Components (must load before Alpine.js) -->
|
||||||
<script src="{% static 'js/alpine-components.js' %}?v={{ version|default:'1.0' }}"></script>
|
<script src="{% static 'js/alpine-components.js' %}?v={{ version|default:'1.0' }}"></script>
|
||||||
|
|||||||
438
templates/cotton/advanced_filters.html
Normal file
438
templates/cotton/advanced_filters.html
Normal 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>
|
||||||
381
templates/cotton/enhanced_park_card.html
Normal file
381
templates/cotton/enhanced_park_card.html
Normal 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 %}
|
||||||
@@ -54,13 +54,19 @@ Features:
|
|||||||
this.open = false;
|
this.open = false;
|
||||||
this.suggestions = [];
|
this.suggestions = [];
|
||||||
this.selectedIndex = -1;
|
this.selectedIndex = -1;
|
||||||
|
// Trigger search update if HTMX is available
|
||||||
|
if (typeof htmx !== 'undefined' && this.$refs.searchInput) {
|
||||||
htmx.trigger(this.$refs.searchInput, 'keyup');
|
htmx.trigger(this.$refs.searchInput, 'keyup');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
selectSuggestion(suggestion) {
|
selectSuggestion(suggestion) {
|
||||||
this.search = suggestion.name || suggestion;
|
this.search = suggestion.name || suggestion;
|
||||||
this.open = false;
|
this.open = false;
|
||||||
this.selectedIndex = -1;
|
this.selectedIndex = -1;
|
||||||
|
// Trigger search update if HTMX is available
|
||||||
|
if (typeof htmx !== 'undefined' && this.$refs.searchInput) {
|
||||||
htmx.trigger(this.$refs.searchInput, 'keyup');
|
htmx.trigger(this.$refs.searchInput, 'keyup');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
handleKeydown(event) {
|
handleKeydown(event) {
|
||||||
if (!this.open) return;
|
if (!this.open) return;
|
||||||
|
|||||||
495
templates/parks/enhanced_park_list.html
Normal file
495
templates/parks/enhanced_park_list.html
Normal 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 %}
|
||||||
280
templates/parks/partials/enhanced_park_list.html
Normal file
280
templates/parks/partials/enhanced_park_list.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user