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

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

View File

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

View File

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