Enhance park search with autocomplete and improved filtering options

Introduce autocomplete for park searches, optimize park data fetching with select_related and prefetch_related, add new API endpoints for autocomplete and quick filters, and refactor the park list view to use new Django Cotton components for a more dynamic and user-friendly experience.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: c446bc9e-66df-438c-a86c-f53e6da13649
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
This commit is contained in:
pac7
2025-09-23 21:44:12 +00:00
parent 7feb7c462d
commit 4c954fff6f
13 changed files with 1588 additions and 245 deletions

View File

@@ -54,6 +54,10 @@ outputType = "webview"
localPort = 5000 localPort = 5000
externalPort = 80 externalPort = 80
[[ports]]
localPort = 40077
externalPort = 3002
[[ports]] [[ports]]
localPort = 41923 localPort = 41923
externalPort = 3000 externalPort = 3000

View File

@@ -1,5 +1,5 @@
from django.urls import path, include from django.urls import path, include
from . import views, views_search from . import views, views_search, views_autocomplete
from apps.rides.views import ParkSingleCategoryListView from apps.rides.views import ParkSingleCategoryListView
from .views_roadtrip import ( from .views_roadtrip import (
RoadTripPlannerView, RoadTripPlannerView,
@@ -30,6 +30,9 @@ urlpatterns = [
path("areas/", views.get_park_areas, name="get_park_areas"), path("areas/", views.get_park_areas, name="get_park_areas"),
path("suggest_parks/", views_search.suggest_parks, name="suggest_parks"), path("suggest_parks/", views_search.suggest_parks, name="suggest_parks"),
path("search/", views.search_parks, name="search_parks"), path("search/", views.search_parks, name="search_parks"),
# Enhanced search endpoints
path("api/autocomplete/", views_autocomplete.ParkAutocompleteView.as_view(), name="park_autocomplete"),
path("api/quick-filters/", views_autocomplete.QuickFilterSuggestionsView.as_view(), name="quick_filter_suggestions"),
# Road trip planning URLs # Road trip planning URLs
path("roadtrip/", RoadTripPlannerView.as_view(), name="roadtrip_planner"), path("roadtrip/", RoadTripPlannerView.as_view(), name="roadtrip_planner"),
path("roadtrip/create/", CreateTripView.as_view(), name="roadtrip_create"), path("roadtrip/create/", CreateTripView.as_view(), name="roadtrip_create"),

View File

@@ -245,15 +245,56 @@ class ParkListView(HTMXFilterableMixin, ListView):
return get_view_mode(self.request) return get_view_mode(self.request)
def get_queryset(self) -> QuerySet[Park]: def get_queryset(self) -> QuerySet[Park]:
"""Get optimized queryset with filter service""" """Get optimized queryset with enhanced filtering and proper relations"""
try: try:
# Use filter service for optimized filtering # Start with optimized base queryset
filter_params = dict(self.request.GET.items()) queryset = (
queryset = self.filter_service.get_filtered_queryset(filter_params) get_base_park_queryset()
.select_related(
'operator',
'property_owner',
'location',
'banner_image',
'card_image'
)
.prefetch_related(
'photos',
'rides__manufacturer',
'areas'
)
)
# Also create filterset for form rendering # Use filter service for enhanced filtering
self.filterset = self.filter_class(self.request.GET, queryset=queryset) filter_params = self._get_clean_filter_params()
# Apply ordering
ordering = self.request.GET.get('ordering', 'name')
if ordering:
# Validate ordering to prevent SQL injection
valid_orderings = [
'name', '-name',
'average_rating', '-average_rating',
'coaster_count', '-coaster_count',
'ride_count', '-ride_count',
'opening_date', '-opening_date'
]
if ordering in valid_orderings:
queryset = queryset.order_by(ordering)
else:
queryset = queryset.order_by('name') # Default fallback
# Apply other filters through service
filtered_queryset = self.filter_service.get_filtered_queryset(filter_params)
# Combine with optimized queryset maintaining the optimizations
final_queryset = queryset.filter(
pk__in=filtered_queryset.values_list('pk', flat=True)
)
# Create filterset for form rendering
self.filterset = self.filter_class(self.request.GET, queryset=final_queryset)
return self.filterset.qs return self.filterset.qs
except Exception as e: except Exception as e:
messages.error(self.request, f"Error loading parks: {str(e)}") messages.error(self.request, f"Error loading parks: {str(e)}")
queryset = self.model.objects.none() queryset = self.model.objects.none()
@@ -275,6 +316,12 @@ class ParkListView(HTMXFilterableMixin, ListView):
filter_counts = self.filter_service.get_filter_counts() filter_counts = self.filter_service.get_filter_counts()
popular_filters = self.filter_service.get_popular_filters() popular_filters = self.filter_service.get_popular_filters()
# Calculate active filters for chips component
active_filters = {}
for key, value in self.request.GET.items():
if key not in ['page', 'view_mode'] and value:
active_filters[key] = value
context.update( context.update(
{ {
"view_mode": self.get_view_mode(), "view_mode": self.get_view_mode(),
@@ -282,6 +329,9 @@ class ParkListView(HTMXFilterableMixin, ListView):
"search_query": self.request.GET.get("search", ""), "search_query": self.request.GET.get("search", ""),
"filter_counts": filter_counts, "filter_counts": filter_counts,
"popular_filters": popular_filters, "popular_filters": popular_filters,
"active_filters": active_filters,
"filter_count": len(active_filters),
"current_ordering": self.request.GET.get("ordering", "name"),
"total_results": ( "total_results": (
context.get("paginator").count context.get("paginator").count
if context.get("paginator") if context.get("paginator")

View File

@@ -0,0 +1,178 @@
"""
Park search autocomplete views for enhanced search functionality.
Provides fast, cached autocomplete suggestions for park search.
"""
from typing import Dict, List, Any
from django.http import JsonResponse
from django.views import View
from django.core.cache import cache
from django.db.models import Q
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from .models import Park
from .models.companies import Company
from .services.filter_service import ParkFilterService
class ParkAutocompleteView(View):
"""
Provides autocomplete suggestions for park search.
Returns JSON with park names, operators, and location suggestions.
"""
def get(self, request):
"""Handle GET request for autocomplete suggestions."""
query = request.GET.get('q', '').strip()
if len(query) < 2:
return JsonResponse({
'suggestions': [],
'message': 'Type at least 2 characters to search'
})
# Check cache first
cache_key = f"park_autocomplete:{query.lower()}"
cached_result = cache.get(cache_key)
if cached_result:
return JsonResponse(cached_result)
# Generate suggestions
suggestions = self._get_suggestions(query)
# Cache results for 5 minutes
result = {
'suggestions': suggestions,
'query': query
}
cache.set(cache_key, result, 300)
return JsonResponse(result)
def _get_suggestions(self, query: str) -> List[Dict[str, Any]]:
"""Generate autocomplete suggestions based on query."""
suggestions = []
# Park name suggestions (top 5)
park_suggestions = self._get_park_suggestions(query)
suggestions.extend(park_suggestions)
# Operator suggestions (top 3)
operator_suggestions = self._get_operator_suggestions(query)
suggestions.extend(operator_suggestions)
# Location suggestions (top 3)
location_suggestions = self._get_location_suggestions(query)
suggestions.extend(location_suggestions)
# Remove duplicates and limit results
seen = set()
unique_suggestions = []
for suggestion in suggestions:
key = suggestion['name'].lower()
if key not in seen:
seen.add(key)
unique_suggestions.append(suggestion)
return unique_suggestions[:10] # Limit to 10 suggestions
def _get_park_suggestions(self, query: str) -> List[Dict[str, Any]]:
"""Get park name suggestions."""
parks = Park.objects.filter(
name__icontains=query,
status='OPERATING'
).select_related('operator').order_by('name')[:5]
suggestions = []
for park in parks:
suggestion = {
'name': park.name,
'type': 'park',
'operator': park.operator.name if park.operator else None,
'url': f'/parks/{park.slug}/' if park.slug else None
}
suggestions.append(suggestion)
return suggestions
def _get_operator_suggestions(self, query: str) -> List[Dict[str, Any]]:
"""Get operator suggestions."""
operators = Company.objects.filter(
roles__contains=['OPERATOR'],
name__icontains=query
).order_by('name')[:3]
suggestions = []
for operator in operators:
suggestion = {
'name': operator.name,
'type': 'operator',
'park_count': operator.operated_parks.filter(status='OPERATING').count()
}
suggestions.append(suggestion)
return suggestions
def _get_location_suggestions(self, query: str) -> List[Dict[str, Any]]:
"""Get location (city/country) suggestions."""
# Get unique cities
city_parks = Park.objects.filter(
location__city__icontains=query,
status='OPERATING'
).select_related('location').order_by('location__city').distinct()[:2]
# Get unique countries
country_parks = Park.objects.filter(
location__country__icontains=query,
status='OPERATING'
).select_related('location').order_by('location__country').distinct()[:2]
suggestions = []
# Add city suggestions
for park in city_parks:
if park.location and park.location.city:
city_name = park.location.city
if park.location.country:
city_name += f", {park.location.country}"
suggestion = {
'name': city_name,
'type': 'location',
'location_type': 'city'
}
suggestions.append(suggestion)
# Add country suggestions
for park in country_parks:
if park.location and park.location.country:
suggestion = {
'name': park.location.country,
'type': 'location',
'location_type': 'country'
}
suggestions.append(suggestion)
return suggestions
@method_decorator(cache_page(60 * 5), name='dispatch') # Cache for 5 minutes
class QuickFilterSuggestionsView(View):
"""
Provides quick filter suggestions and popular filters.
Used for search dropdown quick actions.
"""
def get(self, request):
"""Handle GET request for quick filter suggestions."""
filter_service = ParkFilterService()
popular_filters = filter_service.get_popular_filters()
filter_counts = filter_service.get_filter_counts()
return JsonResponse({
'quick_filters': popular_filters.get('quick_filters', []),
'filter_counts': filter_counts,
'recommended_sorts': popular_filters.get('recommended_sorts', [])
})

View File

@@ -581,6 +581,9 @@
.mt-auto { .mt-auto {
margin-top: auto; margin-top: auto;
} }
.-mr-1 {
margin-right: calc(var(--spacing) * -1);
}
.mr-1 { .mr-1 {
margin-right: calc(var(--spacing) * 1); margin-right: calc(var(--spacing) * 1);
} }
@@ -788,6 +791,9 @@
.min-h-\[120px\] { .min-h-\[120px\] {
min-height: 120px; min-height: 120px;
} }
.min-h-\[400px\] {
min-height: 400px;
}
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
} }
@@ -929,6 +935,9 @@
.grow { .grow {
flex-grow: 1; flex-grow: 1;
} }
.origin-top-right {
transform-origin: top right;
}
.-translate-x-1\/2 { .-translate-x-1\/2 {
--tw-translate-x: calc(calc(1/2 * 100%) * -1); --tw-translate-x: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y); translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1322,9 +1331,6 @@
.border-gray-300 { .border-gray-300 {
border-color: var(--color-gray-300); border-color: var(--color-gray-300);
} }
.border-gray-600 {
border-color: var(--color-gray-600);
}
.border-gray-700 { .border-gray-700 {
border-color: var(--color-gray-700); border-color: var(--color-gray-700);
} }
@@ -1472,9 +1478,6 @@
.bg-gray-600 { .bg-gray-600 {
background-color: var(--color-gray-600); background-color: var(--color-gray-600);
} }
.bg-gray-700 {
background-color: var(--color-gray-700);
}
.bg-gray-800 { .bg-gray-800 {
background-color: var(--color-gray-800); background-color: var(--color-gray-800);
} }
@@ -1758,6 +1761,9 @@
.object-cover { .object-cover {
object-fit: cover; object-fit: cover;
} }
.p-0\.5 {
padding: calc(var(--spacing) * 0.5);
}
.p-1 { .p-1 {
padding: calc(var(--spacing) * 1); padding: calc(var(--spacing) * 1);
} }
@@ -1857,6 +1863,9 @@
.pr-10 { .pr-10 {
padding-right: calc(var(--spacing) * 10); padding-right: calc(var(--spacing) * 10);
} }
.pr-12 {
padding-right: calc(var(--spacing) * 12);
}
.pr-16 { .pr-16 {
padding-right: calc(var(--spacing) * 16); padding-right: calc(var(--spacing) * 16);
} }
@@ -2133,6 +2142,9 @@
.text-yellow-800 { .text-yellow-800 {
color: var(--color-yellow-800); color: var(--color-yellow-800);
} }
.capitalize {
text-transform: capitalize;
}
.uppercase { .uppercase {
text-transform: uppercase; text-transform: uppercase;
} }
@@ -2142,11 +2154,6 @@
.underline-offset-4 { .underline-offset-4 {
text-underline-offset: 4px; text-underline-offset: 4px;
} }
.placeholder-gray-400 {
&::placeholder {
color: var(--color-gray-400);
}
}
.placeholder-gray-500 { .placeholder-gray-500 {
&::placeholder { &::placeholder {
color: var(--color-gray-500); color: var(--color-gray-500);
@@ -2890,13 +2897,6 @@
} }
} }
} }
.hover\:text-white {
&:hover {
@media (hover: hover) {
color: var(--color-white);
}
}
}
.hover\:underline { .hover\:underline {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -3256,11 +3256,6 @@
grid-column: span 3 / span 3; grid-column: span 3 / span 3;
} }
} }
.md\:mt-0 {
@media (width >= 48rem) {
margin-top: calc(var(--spacing) * 0);
}
}
.md\:mb-8 { .md\:mb-8 {
@media (width >= 48rem) { @media (width >= 48rem) {
margin-bottom: calc(var(--spacing) * 8); margin-bottom: calc(var(--spacing) * 8);
@@ -3296,11 +3291,6 @@
height: 140px; height: 140px;
} }
} }
.md\:h-full {
@media (width >= 48rem) {
height: 100%;
}
}
.md\:w-7 { .md\:w-7 {
@media (width >= 48rem) { @media (width >= 48rem) {
width: calc(var(--spacing) * 7); width: calc(var(--spacing) * 7);
@@ -3311,11 +3301,6 @@
width: calc(var(--spacing) * 48); width: calc(var(--spacing) * 48);
} }
} }
.md\:flex-shrink-0 {
@media (width >= 48rem) {
flex-shrink: 0;
}
}
.md\:grid-cols-2 { .md\:grid-cols-2 {
@media (width >= 48rem) { @media (width >= 48rem) {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -3341,21 +3326,6 @@
align-items: center; align-items: center;
} }
} }
.md\:items-end {
@media (width >= 48rem) {
align-items: flex-end;
}
}
.md\:items-start {
@media (width >= 48rem) {
align-items: flex-start;
}
}
.md\:justify-between {
@media (width >= 48rem) {
justify-content: space-between;
}
}
.md\:gap-4 { .md\:gap-4 {
@media (width >= 48rem) { @media (width >= 48rem) {
gap: calc(var(--spacing) * 4); gap: calc(var(--spacing) * 4);
@@ -4390,6 +4360,18 @@
} }
} }
} }
.dark\:hover\:bg-blue-800\/50 {
@media (prefers-color-scheme: dark) {
&:hover {
@media (hover: hover) {
background-color: color-mix(in srgb, oklch(42.4% 0.199 265.638) 50%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-blue-800) 50%, transparent);
}
}
}
}
}
.dark\:hover\:bg-blue-900 { .dark\:hover\:bg-blue-900 {
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
&:hover { &:hover {
@@ -4662,6 +4644,13 @@
} }
} }
} }
.dark\:focus\:border-blue-500 {
@media (prefers-color-scheme: dark) {
&:focus {
border-color: var(--color-blue-500);
}
}
}
.dark\:focus\:bg-gray-700 { .dark\:focus\:bg-gray-700 {
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
&:focus { &:focus {
@@ -4676,6 +4665,13 @@
} }
} }
} }
.dark\:focus\:ring-blue-500 {
@media (prefers-color-scheme: dark) {
&:focus {
--tw-ring-color: var(--color-blue-500);
}
}
}
.dark\:focus\:ring-blue-600 { .dark\:focus\:ring-blue-600 {
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
&:focus { &:focus {

View File

@@ -0,0 +1,232 @@
{% comment %}
Enhanced Search Component - Django Cotton Version
Advanced search input with autocomplete suggestions, debouncing, and loading states.
Provides real-time search with suggestions and filtering integration.
Usage Examples:
<c-enhanced_search
placeholder="Search parks by name, location, or features..."
current_value=""
/>
<c-enhanced_search
placeholder="Find your perfect park..."
current_value="disney"
autocomplete_url="/parks/suggest/"
class="custom-class"
/>
Parameters:
- placeholder: Search input placeholder text (default: "Search parks...")
- current_value: Current search value (optional)
- autocomplete_url: URL for autocomplete suggestions (optional)
- debounce_delay: Debounce delay in milliseconds (default: 300)
- class: Additional CSS classes (optional)
Features:
- Real-time search with debouncing
- Autocomplete dropdown with suggestions
- Loading states and indicators
- HTMX integration for seamless search
- Keyboard navigation support
- Clear button functionality
{% endcomment %}
<c-vars
placeholder="Search parks..."
current_value=""
autocomplete_url=""
debounce_delay="300"
class=""
/>
<div class="relative w-full {{ class }}"
x-data="{
open: false,
search: '{{ current_value }}',
suggestions: [],
loading: false,
selectedIndex: -1,
clearSearch() {
this.search = '';
this.open = false;
this.suggestions = [];
this.selectedIndex = -1;
htmx.trigger(this.$refs.searchInput, 'keyup');
},
selectSuggestion(suggestion) {
this.search = suggestion.name || suggestion;
this.open = false;
this.selectedIndex = -1;
htmx.trigger(this.$refs.searchInput, 'keyup');
},
handleKeydown(event) {
if (!this.open) return;
switch(event.key) {
case 'ArrowDown':
event.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.suggestions.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
break;
case 'Enter':
event.preventDefault();
if (this.selectedIndex >= 0 && this.suggestions[this.selectedIndex]) {
this.selectSuggestion(this.suggestions[this.selectedIndex]);
}
break;
case 'Escape':
this.open = false;
this.selectedIndex = -1;
break;
}
}
}"
@click.away="open = false">
<div class="relative">
<!-- Search Icon -->
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<!-- Search Input -->
<input
x-ref="searchInput"
type="text"
name="search"
x-model="search"
placeholder="{{ placeholder }}"
class="block w-full pl-10 pr-12 py-3 border border-gray-300 rounded-lg leading-5 bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:placeholder-gray-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 transition-colors"
hx-get="{% url 'parks:park_list' %}"
hx-trigger="keyup changed delay:{{ debounce_delay }}ms"
hx-target="#park-results"
hx-include="[name='view_mode'], [name='status'], [name='operator'], [name='ordering']"
hx-indicator="#search-spinner"
hx-push-url="true"
@keydown="handleKeydown"
@input="
if (search.length >= 2) {
{% if autocomplete_url %}
loading = true;
fetch('{{ autocomplete_url }}?q=' + encodeURIComponent(search))
.then(response => response.json())
.then(data => {
suggestions = data.suggestions || [];
open = suggestions.length > 0;
loading = false;
selectedIndex = -1;
})
.catch(() => {
loading = false;
open = false;
});
{% endif %}
} else {
open = false;
suggestions = [];
selectedIndex = -1;
}
"
autocomplete="off"
/>
<!-- Loading Spinner -->
<div id="search-spinner" class="absolute inset-y-0 right-0 pr-3 flex items-center htmx-indicator">
<svg class="animate-spin h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<!-- Clear Button -->
<button
x-show="search.length > 0"
@click="clearSearch()"
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors htmx-indicator:hidden"
aria-label="Clear search"
title="Clear search"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Autocomplete Dropdown -->
<div
x-show="open && suggestions.length > 0"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg dark:bg-gray-800 dark:border-gray-700 max-h-60 overflow-y-auto"
style="display: none;"
>
<div class="py-1">
<template x-for="(suggestion, index) in suggestions" :key="index">
<button
type="button"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 flex items-center justify-between"
:class="{ 'bg-gray-100 dark:bg-gray-700': selectedIndex === index }"
@click="selectSuggestion(suggestion)"
@mouseenter="selectedIndex = index"
>
<span x-text="suggestion.name || suggestion"></span>
<template x-if="suggestion.type">
<span class="text-xs text-gray-500 dark:text-gray-400 capitalize" x-text="suggestion.type"></span>
</template>
</button>
</template>
</div>
<!-- Quick Filters -->
{% if autocomplete_url %}
<div class="border-t border-gray-200 dark:border-gray-700 p-2">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Quick Filters:</div>
<div class="flex flex-wrap gap-1">
<button
type="button"
class="inline-flex items-center px-2 py-1 text-xs font-medium text-blue-700 bg-blue-50 rounded-md hover:bg-blue-100 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-800/50"
hx-get="{% url 'parks:park_list' %}?has_coasters=True"
hx-target="#park-results"
hx-push-url="true"
@click="open = false"
>
Parks with Coasters
</button>
<button
type="button"
class="inline-flex items-center px-2 py-1 text-xs font-medium text-blue-700 bg-blue-50 rounded-md hover:bg-blue-100 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-800/50"
hx-get="{% url 'parks:park_list' %}?min_rating=4"
hx-target="#park-results"
hx-push-url="true"
@click="open = false"
>
Highly Rated
</button>
<button
type="button"
class="inline-flex items-center px-2 py-1 text-xs font-medium text-blue-700 bg-blue-50 rounded-md hover:bg-blue-100 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-800/50"
hx-get="{% url 'parks:park_list' %}?park_type=disney"
hx-target="#park-results"
hx-push-url="true"
@click="open = false"
>
Disney Parks
</button>
</div>
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,81 @@
{% comment %}
Filter Chips Component - Django Cotton Version
Displays active filters as removable chips/badges with clear functionality.
Shows current filter state and allows users to remove individual filters.
Usage Examples:
<c-filter_chips filters=active_filters base_url="/parks/" />
Parameters:
- filters: Dictionary of active filters (required)
- base_url: Base URL for filter removal links (default: current URL)
- class: Additional CSS classes (optional)
Features:
- Clean chip design with remove buttons
- HTMX integration for seamless removal
- Support for various filter types
- Accessible with proper ARIA labels
- Shows filter count in chips
{% endcomment %}
<c-vars
filters
base_url=""
class=""
/>
{% if filters %}
<div class="flex flex-wrap gap-2 {{ class }}">
{% for filter_name, filter_value in filters.items %}
{% if filter_value and filter_name != 'page' and filter_name != 'view_mode' %}
<div class="inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-blue-700 bg-blue-50 rounded-full border border-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700/50">
<span class="capitalize">{{ filter_name|title }}:</span>
<span class="font-semibold">
{% if filter_value == 'True' %}
Yes
{% elif filter_value == 'False' %}
No
{% else %}
{{ filter_value }}
{% endif %}
</span>
<button
type="button"
class="ml-1 p-0.5 text-blue-600 hover:text-blue-800 hover:bg-blue-100 rounded-full dark:text-blue-300 dark:hover:text-blue-200 dark:hover:bg-blue-800/50 transition-colors"
hx-get="{% if base_url %}{{ base_url }}{% else %}{{ request.path }}{% endif %}?{% for name, value in request.GET.items %}{% if name != filter_name and name != 'page' %}{{ name }}={{ value }}&{% endif %}{% endfor %}"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#search-spinner"
aria-label="Remove {{ filter_name }} filter"
title="Remove filter"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{% endif %}
{% endfor %}
{% if filters|length > 1 %}
<button
type="button"
class="inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-gray-600 bg-gray-100 rounded-full border border-gray-200 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600 transition-colors"
hx-get="{% if base_url %}{{ base_url }}{% else %}{{ request.path }}{% endif %}"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#search-spinner"
aria-label="Clear all filters"
title="Clear all filters"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Clear all
</button>
{% endif %}
</div>
{% endif %}

View File

@@ -0,0 +1,122 @@
{% comment %}
Result Statistics Component - Django Cotton Version
Displays result counts, filter summaries, and statistics for park listings.
Shows current page info, total results, and search context.
Usage Examples:
<c-result_stats
total_results=50
page_obj=page_obj
search_query="disney"
/>
<c-result_stats
total_results=0
is_search=True
search_query="nonexistent"
class="custom-class"
/>
Parameters:
- total_results: Total number of results (required)
- page_obj: Django page object for pagination info (optional)
- search_query: Current search query (optional)
- is_search: Whether this is a search result (default: False)
- filter_count: Number of active filters (optional)
- class: Additional CSS classes (optional)
Features:
- Clear result count display
- Search context information
- Pagination information
- Filter summary
- Responsive design
{% endcomment %}
<c-vars
total_results
page_obj=""
search_query=""
is_search=""
filter_count=""
class=""
/>
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 {{ class }}">
<div class="flex items-center gap-4">
<!-- Result Count -->
<div class="flex items-center gap-1">
{% if total_results == 0 %}
<span class="font-medium text-gray-500 dark:text-gray-400">
{% if is_search %}
No parks found
{% if search_query %}
for "{{ search_query }}"
{% endif %}
{% else %}
No parks available
{% endif %}
</span>
{% elif total_results == 1 %}
<span class="font-medium">1 park</span>
{% if is_search and search_query %}
<span>found for "{{ search_query }}"</span>
{% endif %}
{% else %}
<span class="font-medium">{{ total_results|floatformat:0 }} parks</span>
{% if is_search and search_query %}
<span>found for "{{ search_query }}"</span>
{% endif %}
{% endif %}
</div>
<!-- Filter Indicator -->
{% if filter_count and filter_count > 0 %}
<div class="flex items-center gap-1 text-blue-600 dark:text-blue-400">
<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="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
<span>{{ filter_count }} filter{{ filter_count|pluralize }} active</span>
</div>
{% endif %}
</div>
<!-- Page Information -->
{% if page_obj and page_obj.has_other_pages %}
<div class="flex items-center gap-2">
<span>
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.start_index and page_obj.end_index %}
<span class="text-gray-400 dark:text-gray-500">|</span>
<span>
Showing {{ page_obj.start_index }}-{{ page_obj.end_index }}
</span>
{% endif %}
</div>
{% endif %}
</div>
<!-- Search Suggestions -->
{% if total_results == 0 and is_search %}
<div class="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div class="flex items-start gap-2">
<svg class="w-5 h-5 mt-0.5 text-yellow-600 dark:text-yellow-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 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-sm">
<p class="font-medium text-yellow-800 dark:text-yellow-200">No results found</p>
<p class="mt-1 text-yellow-700 dark:text-yellow-300">
Try adjusting your search or removing some filters to see more results.
</p>
<div class="mt-2 space-y-1 text-yellow-600 dark:text-yellow-400">
<p>• Check your spelling</p>
<p>• Try more general terms</p>
<p>• Remove filters to broaden your search</p>
</div>
</div>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,193 @@
{% comment %}
Sort Controls Component - Django Cotton Version
Provides sorting dropdown with common sort options for park listings.
Integrates with HTMX for seamless sorting without page reloads.
Usage Examples:
<c-sort_controls current_sort="-average_rating" />
<c-sort_controls
current_sort="name"
options=custom_sort_options
class="custom-class"
/>
Parameters:
- current_sort: Currently selected sort option (default: "name")
- options: Custom sort options list (optional, uses defaults if not provided)
- class: Additional CSS classes (optional)
Features:
- Dropdown with common sort options
- HTMX integration for seamless sorting
- Visual indicators for current sort
- Accessible with proper ARIA labels
- Support for ascending/descending indicators
{% endcomment %}
<c-vars
current_sort="name"
options=""
class=""
/>
<div class="relative inline-block text-left {{ class }}" x-data="{ open: false }">
<div>
<button
type="button"
class="inline-flex items-center justify-center w-full px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700"
@click="open = !open"
aria-expanded="true"
aria-haspopup="true"
aria-label="Sort options"
>
<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="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
</svg>
Sort by
{% if current_sort %}
{% if current_sort == 'name' %}
<span class="ml-1">: Name (A-Z)</span>
{% elif current_sort == '-name' %}
<span class="ml-1">: Name (Z-A)</span>
{% elif current_sort == '-average_rating' %}
<span class="ml-1">: Highest Rated</span>
{% elif current_sort == 'average_rating' %}
<span class="ml-1">: Lowest Rated</span>
{% elif current_sort == '-coaster_count' %}
<span class="ml-1">: Most Coasters</span>
{% elif current_sort == 'coaster_count' %}
<span class="ml-1">: Fewest Coasters</span>
{% elif current_sort == '-ride_count' %}
<span class="ml-1">: Most Rides</span>
{% elif current_sort == 'ride_count' %}
<span class="ml-1">: Fewest Rides</span>
{% elif current_sort == '-opening_date' %}
<span class="ml-1">: Newest First</span>
{% elif current_sort == 'opening_date' %}
<span class="ml-1">: Oldest First</span>
{% else %}
<span class="ml-1">: {{ current_sort }}</span>
{% endif %}
{% endif %}
<svg class="w-5 h-5 ml-2 -mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
<div
x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute right-0 z-50 w-56 mt-2 origin-top-right bg-white border border-gray-200 rounded-md shadow-lg dark:bg-gray-800 dark:border-gray-700"
@click.away="open = false"
style="display: none;"
>
<div class="py-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
{% if options %}
{% for option in options %}
<button
type="button"
class="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 {% if current_sort == option.value %}bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300{% endif %}"
hx-get="{% url 'parks:park_list' %}?{% for name, value in request.GET.items %}{% if name != 'ordering' and name != 'page' %}{{ name }}={{ value }}&{% endif %}{% endfor %}ordering={{ option.value }}"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#search-spinner"
@click="open = false"
role="menuitem"
>
{% if current_sort == option.value %}
<svg class="w-4 h-4 mr-2 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
{% else %}
<span class="w-4 h-4 mr-2"></span>
{% endif %}
{{ option.label }}
</button>
{% endfor %}
{% else %}
<!-- Default sort options -->
<button
type="button"
class="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 {% if current_sort == 'name' %}bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300{% endif %}"
hx-get="{% url 'parks:park_list' %}?{% for name, value in request.GET.items %}{% if name != 'ordering' and name != 'page' %}{{ name }}={{ value }}&{% endif %}{% endfor %}ordering=name"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#search-spinner"
@click="open = false"
>
{% if current_sort == 'name' %}<svg class="w-4 h-4 mr-2 text-blue-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>{% else %}<span class="w-4 h-4 mr-2"></span>{% endif %}
Name (A-Z)
</button>
<button
type="button"
class="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 {% if current_sort == '-name' %}bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300{% endif %}"
hx-get="{% url 'parks:park_list' %}?{% for name, value in request.GET.items %}{% if name != 'ordering' and name != 'page' %}{{ name }}={{ value }}&{% endif %}{% endfor %}ordering=-name"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#search-spinner"
@click="open = false"
>
{% if current_sort == '-name' %}<svg class="w-4 h-4 mr-2 text-blue-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>{% else %}<span class="w-4 h-4 mr-2"></span>{% endif %}
Name (Z-A)
</button>
<button
type="button"
class="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 {% if current_sort == '-average_rating' %}bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300{% endif %}"
hx-get="{% url 'parks:park_list' %}?{% for name, value in request.GET.items %}{% if name != 'ordering' and name != 'page' %}{{ name }}={{ value }}&{% endif %}{% endfor %}ordering=-average_rating"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#search-spinner"
@click="open = false"
>
{% if current_sort == '-average_rating' %}<svg class="w-4 h-4 mr-2 text-blue-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>{% else %}<span class="w-4 h-4 mr-2"></span>{% endif %}
Highest Rated
</button>
<button
type="button"
class="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 {% if current_sort == '-coaster_count' %}bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300{% endif %}"
hx-get="{% url 'parks:park_list' %}?{% for name, value in request.GET.items %}{% if name != 'ordering' and name != 'page' %}{{ name }}={{ value }}&{% endif %}{% endfor %}ordering=-coaster_count"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#search-spinner"
@click="open = false"
>
{% if current_sort == '-coaster_count' %}<svg class="w-4 h-4 mr-2 text-blue-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>{% else %}<span class="w-4 h-4 mr-2"></span>{% endif %}
Most Coasters
</button>
<button
type="button"
class="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 {% if current_sort == '-ride_count' %}bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300{% endif %}"
hx-get="{% url 'parks:park_list' %}?{% for name, value in request.GET.items %}{% if name != 'ordering' and name != 'page' %}{{ name }}={{ value }}&{% endif %}{% endfor %}ordering=-ride_count"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#search-spinner"
@click="open = false"
>
{% if current_sort == '-ride_count' %}<svg class="w-4 h-4 mr-2 text-blue-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>{% else %}<span class="w-4 h-4 mr-2"></span>{% endif %}
Most Rides
</button>
<button
type="button"
class="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 {% if current_sort == '-opening_date' %}bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300{% endif %}"
hx-get="{% url 'parks:park_list' %}?{% for name, value in request.GET.items %}{% if name != 'ordering' and name != 'page' %}{{ name }}={{ value }}&{% endif %}{% endfor %}ordering=-opening_date"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#search-spinner"
@click="open = false"
>
{% if current_sort == '-opening_date' %}<svg class="w-4 h-4 mr-2 text-blue-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>{% else %}<span class="w-4 h-4 mr-2"></span>{% endif %}
Newest First
</button>
{% endif %}
</div>
</div>
</div>

View File

@@ -0,0 +1,67 @@
{% comment %}
View Toggle Component - Django Cotton Version
Provides toggle between grid and list view modes with visual indicators.
Integrates with HTMX for seamless view switching without page reloads.
Usage Examples:
<c-view_toggle current_view="grid" />
<c-view_toggle
current_view="list"
class="custom-class"
/>
Parameters:
- current_view: Currently selected view mode ("grid" or "list", default: "grid")
- class: Additional CSS classes (optional)
Features:
- Clean toggle button design
- Visual indicators for current view
- HTMX integration for seamless switching
- Accessible with proper ARIA labels
- Icons for grid and list views
{% endcomment %}
<c-vars
current_view="grid"
class=""
/>
<div class="inline-flex rounded-lg border border-gray-200 dark:border-gray-700 {{ class }}" role="group" aria-label="View toggle">
<button
type="button"
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-l-lg transition-colors {% if current_view == 'grid' %}bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700{% else %}bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700{% endif %}"
hx-get="{% url 'parks:park_list' %}?{% for name, value in request.GET.items %}{% if name != 'view_mode' and name != 'page' %}{{ name }}={{ value }}&{% endif %}{% endfor %}view_mode=grid"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#search-spinner"
aria-label="Grid view"
aria-pressed="{% if current_view == 'grid' %}true{% else %}false{% endif %}"
title="Grid view"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
<span class="ml-1 hidden sm:inline">Grid</span>
</button>
<button
type="button"
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-r-lg transition-colors {% if current_view == 'list' %}bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700{% else %}bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700{% endif %}"
hx-get="{% url 'parks:park_list' %}?{% for name, value in request.GET.items %}{% if name != 'view_mode' and name != 'page' %}{{ name }}={{ value }}&{% endif %}{% endfor %}view_mode=list"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#search-spinner"
aria-label="List view"
aria-pressed="{% if current_view == 'list' %}true{% else %}false{% endif %}"
title="List view"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
<span class="ml-1 hidden sm:inline">List</span>
</button>
</div>

View File

@@ -1,93 +1,301 @@
{% extends "base/base.html" %} {% extends "base/base.html" %}
{% load static %} {% load static %}
{% load cotton %}
{% block title %}Parks{% endblock %} {% block title %}Parks{% endblock %}
{% block content %} {% block content %}
<div class="container mx-auto px-4 py-6"> <div class="container mx-auto px-4 py-6" x-data="parkListState()">
<!-- Consolidated Search and View Controls Bar --> <!-- Enhanced Header Section -->
<div class="bg-gray-800 rounded-lg p-4 mb-6"> <div class="mb-8">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4"> <div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6">
<!-- Search Section --> <div>
<div class="flex-1 max-w-2xl"> <h1 class="text-3xl lg:text-4xl font-bold text-gray-900 dark:text-white">
<div class="relative"> Theme Parks
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> </h1>
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <p class="mt-2 text-lg text-gray-600 dark:text-gray-400">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path> Discover amazing theme parks around the world
</svg> </p>
</div> </div>
<input
type="text" <!-- Quick Stats -->
name="search" <div class="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
value="{{ search_query }}" <div class="text-center">
<div class="font-bold text-lg text-gray-900 dark:text-white">{{ filter_counts.total_parks|default:0 }}</div>
<div>Total Parks</div>
</div>
<div class="text-center">
<div class="font-bold text-lg text-gray-900 dark:text-white">{{ filter_counts.operating_parks|default:0 }}</div>
<div>Operating</div>
</div>
<div class="text-center">
<div class="font-bold text-lg text-gray-900 dark:text-white">{{ filter_counts.parks_with_coasters|default:0 }}</div>
<div>With Coasters</div>
</div>
</div>
</div>
</div>
<!-- Enhanced Search and Filter Bar -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-6 mb-8">
<div class="space-y-6">
<!-- Main Search Row -->
<div class="flex flex-col lg:flex-row gap-4">
<!-- Enhanced Search Input -->
<div class="flex-1">
<c-enhanced_search
placeholder="Search parks by name, location, or features..." placeholder="Search parks by name, location, or features..."
class="block w-full pl-10 pr-3 py-2 border border-gray-600 rounded-md leading-5 bg-gray-700 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" current_value="{{ search_query }}"
autocomplete_url="{% url 'parks:park_autocomplete' %}"
class="w-full"
/>
</div>
<!-- Controls Row -->
<div class="flex items-center gap-3">
<!-- Sort Controls -->
<c-sort_controls
current_sort="{{ current_ordering }}"
class="min-w-0"
/>
<!-- View Toggle -->
<c-view_toggle
current_view="{{ view_mode }}"
class="flex-shrink-0"
/>
<!-- Filter Toggle Button (Mobile) -->
<button
type="button"
class="lg:hidden inline-flex items-center px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600"
@click="showFilters = !showFilters"
aria-label="Toggle filters"
>
<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="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
<span class="ml-1">Filters</span>
</button>
</div>
</div>
<!-- Advanced Filters Row -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"
x-show="showFilters"
x-transition:enter="transition ease-out duration-200"
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-150"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95">
<!-- Status Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Status
</label>
<select
name="status"
class="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:focus:ring-blue-500"
hx-get="{% url 'parks:park_list' %}" hx-get="{% url 'parks:park_list' %}"
hx-trigger="keyup changed delay:300ms"
hx-target="#park-results" hx-target="#park-results"
hx-include="[name='view_mode']" hx-include="[name='search'], [name='view_mode'], [name='ordering'], [name='operator'], [name='park_type']"
hx-push-url="true"
hx-indicator="#search-spinner"
>
<option value="">All Statuses</option>
<option value="OPERATING" {% if request.GET.status == 'OPERATING' %}selected{% endif %}>Operating</option>
<option value="CLOSED_TEMP" {% if request.GET.status == 'CLOSED_TEMP' %}selected{% endif %}>Temporarily Closed</option>
<option value="CLOSED_PERM" {% if request.GET.status == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
<option value="UNDER_CONSTRUCTION" {% if request.GET.status == 'UNDER_CONSTRUCTION' %}selected{% endif %}>Under Construction</option>
</select>
</div>
<!-- Operator Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Operator
</label>
<select
name="operator"
class="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:focus:ring-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']"
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 request.GET.operator == operator.operator__id|stringformat:"s" %}selected{% endif %}>
{{ operator.operator__name }} ({{ operator.park_count }})
</option>
{% endfor %}
</select>
</div>
<!-- Park Type Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Park Type
</label>
<select
name="park_type"
class="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:focus:ring-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']"
hx-push-url="true"
hx-indicator="#search-spinner"
>
<option value="">All Types</option>
<option value="disney" {% if request.GET.park_type == 'disney' %}selected{% endif %}>Disney Parks</option>
<option value="universal" {% if request.GET.park_type == 'universal' %}selected{% endif %}>Universal Parks</option>
<option value="six_flags" {% if request.GET.park_type == 'six_flags' %}selected{% endif %}>Six Flags</option>
<option value="cedar_fair" {% if request.GET.park_type == 'cedar_fair' %}selected{% endif %}>Cedar Fair</option>
<option value="independent" {% if request.GET.park_type == 'independent' %}selected{% endif %}>Independent</option>
</select>
</div>
<!-- Quick Filters -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Quick Filters
</label>
<div class="space-y-2">
<label class="flex items-center">
<input
type="checkbox"
name="has_coasters"
value="true"
{% if request.GET.has_coasters %}checked{% endif %}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
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']"
hx-push-url="true"
hx-indicator="#search-spinner" hx-indicator="#search-spinner"
/> />
<div id="search-spinner" class="absolute inset-y-0 right-0 pr-3 flex items-center htmx-indicator"> <span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Has Roller Coasters</span>
<svg class="animate-spin h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"> </label>
<label class="flex items-center">
<input
type="checkbox"
name="big_parks_only"
value="true"
{% if request.GET.big_parks_only %}checked{% endif %}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
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']"
hx-push-url="true"
hx-indicator="#search-spinner"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Major Parks (10+ rides)</span>
</label>
</div>
</div>
</div>
<!-- Active Filter Chips -->
{% if active_filters %}
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<c-filter_chips
filters=active_filters
base_url="{% url 'parks:park_list' %}"
class="flex-wrap"
/>
</div>
{% endif %}
</div>
</div>
<!-- Results Section -->
<div class="space-y-6">
<!-- Results Statistics -->
<c-result_stats
total_results="{{ total_results }}"
page_obj="{{ page_obj }}"
search_query="{{ search_query }}"
is_search="{{ is_search }}"
filter_count="{{ filter_count }}"
/>
<!-- Loading Overlay -->
<div id="loading-overlay" class="htmx-indicator">
<div class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-xl">
<div class="flex items-center space-x-3">
<svg class="animate-spin h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
</div> <span class="text-lg font-medium text-gray-900 dark:text-white">Loading parks...</span>
</div>
</div>
<!-- Results Count and View Controls -->
<div class="flex items-center gap-4">
<!-- Results Count -->
<div class="text-gray-300 text-sm whitespace-nowrap">
<span class="font-medium">Parks</span>
{% if total_results %}
<span class="text-gray-400">({{ total_results }} found)</span>
{% endif %}
</div>
<!-- View Mode Toggle -->
<div class="flex bg-gray-700 rounded-lg p-1">
<input type="hidden" name="view_mode" value="{{ view_mode }}" />
<!-- Grid View Button -->
<button
type="button"
class="p-2 rounded-md transition-colors duration-200 {% if view_mode == 'grid' %}bg-blue-600 text-white{% else %}text-gray-400 hover:text-white{% endif %}"
title="Grid View"
hx-get="{% url 'parks:park_list' %}?view_mode=grid"
hx-target="#park-results"
hx-include="[name='search']"
hx-push-url="true"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path>
</svg>
</button>
<!-- List View Button -->
<button
type="button"
class="p-2 rounded-md transition-colors duration-200 {% if view_mode == 'list' %}bg-blue-600 text-white{% else %}text-gray-400 hover:text-white{% endif %}"
title="List View"
hx-get="{% url 'parks:park_list' %}?view_mode=list"
hx-target="#park-results"
hx-include="[name='search']"
hx-push-url="true"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
</svg>
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Results Container --> <!-- Park Results Container -->
<div id="park-results"> <div id="park-results"
hx-indicator="#loading-overlay"
class="min-h-[400px]">
{% include "parks/partials/park_list.html" %} {% include "parks/partials/park_list.html" %}
</div> </div>
</div>
</div> </div>
<!-- AlpineJS State Management -->
<script>
function parkListState() {
return {
showFilters: window.innerWidth >= 1024, // Show on desktop by default
viewMode: '{{ view_mode }}',
searchQuery: '{{ search_query }}',
init() {
// Handle responsive filter visibility
this.handleResize();
window.addEventListener('resize', () => this.handleResize());
// Handle HTMX events
document.addEventListener('htmx:beforeRequest', () => {
this.setLoading(true);
});
document.addEventListener('htmx:afterRequest', () => {
this.setLoading(false);
});
document.addEventListener('htmx:responseError', () => {
this.setLoading(false);
this.showError('Failed to load results. Please try again.');
});
},
handleResize() {
if (window.innerWidth >= 1024) {
this.showFilters = true;
} else {
// Keep current state on mobile
}
},
setLoading(loading) {
// Additional loading state management if needed
},
showError(message) {
// Show error notification
console.error(message);
},
clearAllFilters() {
window.location.href = '{% url "parks:park_list" %}';
}
}
}
</script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,301 @@
{% extends "base/base.html" %}
{% load static %}
{% load cotton %}
{% block title %}Parks{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-6" x-data="parkListState()">
<!-- Enhanced Header Section -->
<div class="mb-8">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6">
<div>
<h1 class="text-3xl lg:text-4xl font-bold text-gray-900 dark:text-white">
Theme Parks
</h1>
<p class="mt-2 text-lg text-gray-600 dark:text-gray-400">
Discover amazing theme parks around the world
</p>
</div>
<!-- Quick Stats -->
<div class="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
<div class="text-center">
<div class="font-bold text-lg text-gray-900 dark:text-white">{{ filter_counts.total_parks|default:0 }}</div>
<div>Total Parks</div>
</div>
<div class="text-center">
<div class="font-bold text-lg text-gray-900 dark:text-white">{{ filter_counts.operating_parks|default:0 }}</div>
<div>Operating</div>
</div>
<div class="text-center">
<div class="font-bold text-lg text-gray-900 dark:text-white">{{ filter_counts.parks_with_coasters|default:0 }}</div>
<div>With Coasters</div>
</div>
</div>
</div>
</div>
<!-- Enhanced Search and Filter Bar -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-6 mb-8">
<div class="space-y-6">
<!-- Main Search Row -->
<div class="flex flex-col lg:flex-row gap-4">
<!-- Enhanced Search Input -->
<div class="flex-1">
<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>
<!-- Controls Row -->
<div class="flex items-center gap-3">
<!-- Sort Controls -->
<c-sort_controls
current_sort="{{ current_ordering }}"
class="min-w-0"
/>
<!-- View Toggle -->
<c-view_toggle
current_view="{{ view_mode }}"
class="flex-shrink-0"
/>
<!-- Filter Toggle Button (Mobile) -->
<button
type="button"
class="lg:hidden inline-flex items-center px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600"
@click="showFilters = !showFilters"
aria-label="Toggle filters"
>
<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="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
<span class="ml-1">Filters</span>
</button>
</div>
</div>
<!-- Advanced Filters Row -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"
x-show="showFilters"
x-transition:enter="transition ease-out duration-200"
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-150"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95">
<!-- Status Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Status
</label>
<select
name="status"
class="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:focus:ring-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']"
hx-push-url="true"
hx-indicator="#search-spinner"
>
<option value="">All Statuses</option>
<option value="OPERATING" {% if request.GET.status == 'OPERATING' %}selected{% endif %}>Operating</option>
<option value="CLOSED_TEMP" {% if request.GET.status == 'CLOSED_TEMP' %}selected{% endif %}>Temporarily Closed</option>
<option value="CLOSED_PERM" {% if request.GET.status == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
<option value="UNDER_CONSTRUCTION" {% if request.GET.status == 'UNDER_CONSTRUCTION' %}selected{% endif %}>Under Construction</option>
</select>
</div>
<!-- Operator Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Operator
</label>
<select
name="operator"
class="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:focus:ring-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']"
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 request.GET.operator == operator.operator__id|stringformat:"s" %}selected{% endif %}>
{{ operator.operator__name }} ({{ operator.park_count }})
</option>
{% endfor %}
</select>
</div>
<!-- Park Type Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Park Type
</label>
<select
name="park_type"
class="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:focus:ring-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']"
hx-push-url="true"
hx-indicator="#search-spinner"
>
<option value="">All Types</option>
<option value="disney" {% if request.GET.park_type == 'disney' %}selected{% endif %}>Disney Parks</option>
<option value="universal" {% if request.GET.park_type == 'universal' %}selected{% endif %}>Universal Parks</option>
<option value="six_flags" {% if request.GET.park_type == 'six_flags' %}selected{% endif %}>Six Flags</option>
<option value="cedar_fair" {% if request.GET.park_type == 'cedar_fair' %}selected{% endif %}>Cedar Fair</option>
<option value="independent" {% if request.GET.park_type == 'independent' %}selected{% endif %}>Independent</option>
</select>
</div>
<!-- Quick Filters -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Quick Filters
</label>
<div class="space-y-2">
<label class="flex items-center">
<input
type="checkbox"
name="has_coasters"
value="true"
{% if request.GET.has_coasters %}checked{% endif %}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
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']"
hx-push-url="true"
hx-indicator="#search-spinner"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Has Roller Coasters</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
name="big_parks_only"
value="true"
{% if request.GET.big_parks_only %}checked{% endif %}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
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']"
hx-push-url="true"
hx-indicator="#search-spinner"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Major Parks (10+ rides)</span>
</label>
</div>
</div>
</div>
<!-- Active Filter Chips -->
{% if active_filters %}
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<c-filter_chips
filters=active_filters
base_url="{% url 'parks:park_list' %}"
class="flex-wrap"
/>
</div>
{% endif %}
</div>
</div>
<!-- Results Section -->
<div class="space-y-6">
<!-- Results Statistics -->
<c-result_stats
total_results="{{ total_results }}"
page_obj="{{ page_obj }}"
search_query="{{ search_query }}"
is_search="{{ is_search }}"
filter_count="{{ filter_count }}"
/>
<!-- Loading Overlay -->
<div id="loading-overlay" class="htmx-indicator">
<div class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-xl">
<div class="flex items-center space-x-3">
<svg class="animate-spin h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-lg font-medium text-gray-900 dark:text-white">Loading parks...</span>
</div>
</div>
</div>
</div>
<!-- Park Results Container -->
<div id="park-results"
hx-indicator="#loading-overlay"
class="min-h-[400px]">
{% include "parks/partials/park_list.html" %}
</div>
</div>
</div>
<!-- AlpineJS State Management -->
<script>
function parkListState() {
return {
showFilters: window.innerWidth >= 1024, // Show on desktop by default
viewMode: '{{ view_mode }}',
searchQuery: '{{ search_query }}',
init() {
// Handle responsive filter visibility
this.handleResize();
window.addEventListener('resize', () => this.handleResize());
// Handle HTMX events
document.addEventListener('htmx:beforeRequest', () => {
this.setLoading(true);
});
document.addEventListener('htmx:afterRequest', () => {
this.setLoading(false);
});
document.addEventListener('htmx:responseError', () => {
this.setLoading(false);
this.showError('Failed to load results. Please try again.');
});
},
handleResize() {
if (window.innerWidth >= 1024) {
this.showFilters = true;
} else {
// Keep current state on mobile
}
},
setLoading(loading) {
// Additional loading state management if needed
},
showError(message) {
// Show error notification
console.error(message);
},
clearAllFilters() {
window.location.href = '{% url "parks:park_list" %}';
}
}
}
</script>
{% endblock %}

View File

@@ -1,119 +1,27 @@
{% load cotton %}
{% if view_mode == 'list' %} {% if view_mode == 'list' %}
<!-- Parks List View --> <!-- Parks List View -->
<div class="space-y-4"> <div class="space-y-4">
{% for park in parks %} {% for park in parks %}
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 overflow-hidden"> <c-park_card park=park view_mode="list" />
<div class="flex flex-col md:flex-row">
{% if park.photos.exists %}
<div class="md:w-48 md:flex-shrink-0">
<img src="{{ park.photos.first.image.url }}"
alt="{{ park.name }}"
class="w-full h-48 md:h-full object-cover">
</div>
{% endif %}
<div class="flex-1 p-6">
<div class="flex flex-col md:flex-row md:items-start md:justify-between">
<div class="flex-1">
<h2 class="text-2xl font-bold mb-2">
<a href="{% url 'parks:park_detail' park.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ park.name }}
</a>
</h2>
{% if park.city or park.state or park.country %}
<p class="text-gray-600 dark:text-gray-400 mb-3">
<i class="mr-1 fas fa-map-marker-alt"></i>
{% spaceless %}
{% if park.city %}{{ park.city }}{% endif %}{% if park.city and park.state %}, {% endif %}{% if park.state %}{{ park.state }}{% endif %}{% if park.country and park.state or park.city %}, {% endif %}{% if park.country %}{{ park.country }}{% endif %}
{% endspaceless %}
</p>
{% endif %}
{% if park.operator %}
<p class="text-blue-600 dark:text-blue-400 mb-3">
{{ park.operator.name }}
</p>
{% endif %}
</div>
<div class="flex flex-col items-start md:items-end gap-2 mt-4 md:mt-0">
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif park.status == 'DEMOLISHED' %}status-demolished
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ park.get_status_display }}
</span>
{% if park.average_rating %}
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
{{ park.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% empty %} {% empty %}
<div class="py-8 text-center"> <div class="py-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No parks found matching your criteria.</p> <p class="text-gray-500 dark:text-gray-400">No parks found matching your criteria.</p>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<!-- Parks Grid View --> <!-- Parks Grid View -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for park in parks %} {% for park in parks %}
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1"> <c-park_card park=park view_mode="grid" />
{% if park.photos.exists %}
<div class="aspect-w-16 aspect-h-9">
<img src="{{ park.photos.first.image.url }}"
alt="{{ park.name }}"
class="object-cover w-full h-48">
</div>
{% endif %}
<div class="p-4">
<h2 class="mb-2 text-xl font-bold">
<a href="{% url 'parks:park_detail' park.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ park.name }}
</a>
</h2>
{% if park.city or park.state or park.country %}
<p class="mb-3 text-gray-600 dark:text-gray-400">
<i class="mr-1 fas fa-map-marker-alt"></i>
{% spaceless %}
{% if park.city %}{{ park.city }}{% endif %}{% if park.city and park.state %}, {% endif %}{% if park.state %}{{ park.state }}{% endif %}{% if park.country and park.state or park.city %}, {% endif %}{% if park.country %}{{ park.country }}{% endif %}
{% endspaceless %}
</p>
{% endif %}
<div class="flex flex-wrap gap-2">
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif park.status == 'DEMOLISHED' %}status-demolished
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ park.get_status_display }}
</span>
{% if park.average_rating %}
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
{{ park.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</div>
{% if park.operator %}
<div class="mt-4 text-sm text-blue-600 dark:text-blue-400">
{{ park.operator.name }}
</div>
{% endif %}
</div>
</div>
{% empty %} {% empty %}
<div class="col-span-full py-8 text-center"> <div class="col-span-full py-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No parks found matching your criteria.</p> <p class="text-gray-500 dark:text-gray-400">No parks found matching your criteria.</p>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<!-- Pagination --> <!-- Pagination -->