Refactor park filtering system and templates

- Updated the filtered_list.html template to extend from base/base.html and improved layout and styling.
- Removed the park_list.html template as its functionality is now integrated into the filtered list.
- Added a new migration to create indexes for improved filtering performance on the parks model.
- Merged migrations to maintain a clean migration history.
- Implemented a ParkFilterService to handle complex filtering logic, aggregations, and caching for park filters.
- Enhanced filter suggestions and popular filters retrieval methods.
- Improved the overall structure and efficiency of the filtering system.
This commit is contained in:
pacnpal
2025-08-20 21:20:10 -04:00
parent 66ed4347a9
commit 652ea149bd
17 changed files with 3224 additions and 515 deletions

View File

@@ -11,6 +11,7 @@ from django_filters import (
FilterSet,
CharFilter,
BooleanFilter,
OrderingFilter,
)
from .models import Park, Company
from .querysets import get_base_park_queryset
@@ -45,7 +46,7 @@ class ParkFilter(FilterSet):
# Status filter with clearer label
status = ChoiceFilter(
field_name="status",
choices=Park._meta.get_field("status").choices,
choices=Park.STATUS_CHOICES,
empty_label=_("Any status"),
label=_("Operating Status"),
help_text=_("Filter parks by their current operating status"),
@@ -53,7 +54,7 @@ class ParkFilter(FilterSet):
# Operator filters with helpful descriptions
operator = ModelChoiceFilter(
field_name="operating_company",
field_name="operator",
queryset=Company.objects.filter(roles__contains=["OPERATOR"]),
empty_label=_("Any operator"),
label=_("Operating Company"),
@@ -128,6 +129,63 @@ class ParkFilter(FilterSet):
help_text=_("Filter parks by state or region"),
)
# Practical filter fields that people actually use
park_type = ChoiceFilter(
method="filter_park_type",
label=_("Park Type"),
help_text=_("Filter by popular park categories"),
choices=[
("disney", _("Disney Parks")),
("universal", _("Universal Parks")),
("six_flags", _("Six Flags")),
("cedar_fair", _("Cedar Fair")),
("independent", _("Independent Parks")),
],
empty_label=_("All parks"),
)
has_coasters = BooleanFilter(
method="filter_has_coasters",
label=_("Has Roller Coasters"),
help_text=_("Show only parks with roller coasters"),
)
min_rating = ChoiceFilter(
method="filter_min_rating",
label=_("Minimum Rating"),
help_text=_("Show parks with at least this rating"),
choices=[
("3", _("3+ stars")),
("4", _("4+ stars")),
("4.5", _("4.5+ stars")),
],
empty_label=_("Any rating"),
)
big_parks_only = BooleanFilter(
method="filter_big_parks",
label=_("Major Parks Only"),
help_text=_("Show only large theme parks (10+ rides)"),
)
# Simple, useful ordering
ordering = OrderingFilter(
fields=(
("name", "name"),
("average_rating", "rating"),
("coaster_count", "coasters"),
("ride_count", "rides"),
),
field_labels={
"name": _("Name (A-Z)"),
"-name": _("Name (Z-A)"),
"-average_rating": _("Highest Rated"),
"-coaster_count": _("Most Coasters"),
"-ride_count": _("Most Rides"),
},
label=_("Sort by"),
)
def filter_search(self, queryset, name, value):
"""Custom search implementation"""
if not value:
@@ -150,14 +208,20 @@ class ParkFilter(FilterSet):
def filter_has_operator(self, queryset, name, value):
"""Filter parks based on whether they have an operator"""
return queryset.filter(operating_company__isnull=not value)
return queryset.filter(operator__isnull=not value)
@property
def qs(self):
"""Override qs property to ensure we always use base queryset with annotations"""
"""
Override qs property to ensure we always use base queryset with annotations
"""
if not hasattr(self, "_qs"):
# Start with the base queryset that includes annotations
base_qs = get_base_park_queryset()
# Start with optimized base queryset
base_qs = (
get_base_park_queryset()
.select_related("operator", "property_owner", "location")
.prefetch_related("photos", "rides__manufacturer")
)
if not self.is_bound:
self._qs = base_qs
@@ -169,7 +233,11 @@ class ParkFilter(FilterSet):
self._qs = base_qs
for name, value in self.form.cleaned_data.items():
if value in [None, "", 0] and name not in ["has_operator"]:
if value in [None, "", 0] and name not in [
"has_operator",
"has_coasters",
"big_parks_only",
]:
continue
self._qs = self.filters[name].filter(self._qs, value)
self._qs = self._qs.distinct()
@@ -213,16 +281,7 @@ class ParkFilter(FilterSet):
distance = Distance(km=radius)
return (
queryset.filter(location__point__distance_lte=(point, distance))
.annotate(
distance=models.functions.Cast(
models.functions.Extract(
models.F("location__point").distance(point)
* 111.32, # Convert degrees to km
"epoch",
),
models.FloatField(),
)
)
.annotate(distance=models.Value(0, output_field=models.FloatField()))
.order_by("distance")
.distinct()
)
@@ -243,6 +302,64 @@ class ParkFilter(FilterSet):
return queryset
return queryset.filter(location__state__icontains=value).distinct()
def filter_park_type(self, queryset, name, value):
"""Filter parks by popular company/brand"""
if not value:
return queryset
# Map common park types to operator name patterns
type_filters = {
"disney": models.Q(operator__name__icontains="Disney"),
"universal": models.Q(operator__name__icontains="Universal"),
"six_flags": models.Q(operator__name__icontains="Six Flags"),
"cedar_fair": models.Q(operator__name__icontains="Cedar Fair")
| models.Q(operator__name__icontains="Cedar Point")
| models.Q(operator__name__icontains="Kings Island")
| models.Q(operator__name__icontains="Canada's Wonderland"),
"independent": ~(
models.Q(operator__name__icontains="Disney")
| models.Q(operator__name__icontains="Universal")
| models.Q(operator__name__icontains="Six Flags")
| models.Q(operator__name__icontains="Cedar Fair")
| models.Q(operator__name__icontains="Cedar Point")
),
}
if value in type_filters:
return queryset.filter(type_filters[value])
return queryset
def filter_has_coasters(self, queryset, name, value):
"""Filter parks based on whether they have roller coasters"""
if value is None:
return queryset
if value:
return queryset.filter(coaster_count__gt=0)
else:
return queryset.filter(
models.Q(coaster_count__isnull=True) | models.Q(coaster_count=0)
)
def filter_min_rating(self, queryset, name, value):
"""Filter parks by minimum rating"""
if not value:
return queryset
try:
min_rating = float(value)
return queryset.filter(average_rating__gte=min_rating)
except (ValueError, TypeError):
return queryset
def filter_big_parks(self, queryset, name, value):
"""Filter to show only major parks with many rides"""
if not value:
return queryset
return queryset.filter(ride_count__gte=10)
def _geocode_location(self, location_string):
"""
Geocode a location string using OpenStreetMap Nominatim.

View File

@@ -0,0 +1,62 @@
# Generated manually for enhanced filtering performance
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False # Required for CREATE INDEX CONCURRENTLY
dependencies = [
('parks', '0001_initial'), # Adjust this to the latest migration
]
operations = [
# Add indexes for commonly filtered fields
migrations.RunSQL(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_status_idx ON parks_park (status);",
reverse_sql="DROP INDEX IF EXISTS parks_park_status_idx;",
),
migrations.RunSQL(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_operator_id_idx ON parks_park (operator_id);",
reverse_sql="DROP INDEX IF EXISTS parks_park_operator_id_idx;",
),
migrations.RunSQL(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_average_rating_idx ON parks_park (average_rating);",
reverse_sql="DROP INDEX IF EXISTS parks_park_average_rating_idx;",
),
migrations.RunSQL(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_size_acres_idx ON parks_park (size_acres);",
reverse_sql="DROP INDEX IF EXISTS parks_park_size_acres_idx;",
),
migrations.RunSQL(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_coaster_count_idx ON parks_park (coaster_count);",
reverse_sql="DROP INDEX IF EXISTS parks_park_coaster_count_idx;",
),
migrations.RunSQL(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_ride_count_idx ON parks_park (ride_count);",
reverse_sql="DROP INDEX IF EXISTS parks_park_ride_count_idx;",
),
migrations.RunSQL(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_updated_at_idx ON parks_park (updated_at);",
reverse_sql="DROP INDEX IF EXISTS parks_park_updated_at_idx;",
),
# Composite indexes for common filter combinations
migrations.RunSQL(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_status_rating_idx ON parks_park (status, average_rating);",
reverse_sql="DROP INDEX IF EXISTS parks_park_status_rating_idx;",
),
migrations.RunSQL(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_operator_status_idx ON parks_park (operator_id, status);",
reverse_sql="DROP INDEX IF EXISTS parks_park_operator_status_idx;",
),
# Index for parks with coasters (coaster_count > 0)
migrations.RunSQL(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_has_coasters_idx ON parks_park (coaster_count) WHERE coaster_count > 0;",
reverse_sql="DROP INDEX IF EXISTS parks_park_has_coasters_idx;",
),
# Index for big parks (ride_count >= 10)
migrations.RunSQL(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS parks_park_big_parks_idx ON parks_park (ride_count) WHERE ride_count >= 10;",
reverse_sql="DROP INDEX IF EXISTS parks_park_big_parks_idx;",
),
]

View File

@@ -0,0 +1,13 @@
# Generated by Django 5.2.5 on 2025-08-21 00:20
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("parks", "0001_add_filter_indexes"),
("parks", "0004_fix_pghistory_triggers"),
]
operations = []

View File

@@ -1,4 +1,5 @@
from .roadtrip import RoadTripService
from .park_management import ParkService, LocationService
from .filter_service import ParkFilterService
__all__ = ["RoadTripService", "ParkService", "LocationService"]
__all__ = ["RoadTripService", "ParkService", "LocationService", "ParkFilterService"]

View File

@@ -0,0 +1,304 @@
"""
Park Filter Service
Provides filtering functionality, aggregations, and caching for park filters.
This service handles complex filter logic and provides useful filter statistics.
"""
from typing import Dict, List, Any, Optional
from django.db.models import QuerySet, Count, Q
from django.core.cache import cache
from django.conf import settings
from ..models import Park, Company
from ..querysets import get_base_park_queryset
class ParkFilterService:
"""
Service class for handling park filtering operations, aggregations,
and providing filter suggestions based on available data.
"""
CACHE_TIMEOUT = getattr(settings, "PARK_FILTER_CACHE_TIMEOUT", 300) # 5 minutes
def __init__(self):
self.cache_prefix = "park_filter"
def get_filter_counts(
self, base_queryset: Optional[QuerySet] = None
) -> Dict[str, Any]:
"""
Get counts for various filter options to show users what's available.
Args:
base_queryset: Optional base queryset to use for calculations
Returns:
Dictionary containing counts for different filter categories
"""
cache_key = f"{self.cache_prefix}:filter_counts"
cached_result = cache.get(cache_key)
if cached_result is not None:
return cached_result
if base_queryset is None:
base_queryset = get_base_park_queryset()
# Calculate filter counts
filter_counts = {
"total_parks": base_queryset.count(),
"operating_parks": base_queryset.filter(status="OPERATING").count(),
"parks_with_coasters": base_queryset.filter(coaster_count__gt=0).count(),
"big_parks": base_queryset.filter(ride_count__gte=10).count(),
"highly_rated": base_queryset.filter(average_rating__gte=4.0).count(),
"park_types": self._get_park_type_counts(base_queryset),
"top_operators": self._get_top_operators(base_queryset),
"countries": self._get_country_counts(base_queryset),
}
# Cache the result
cache.set(cache_key, filter_counts, self.CACHE_TIMEOUT)
return filter_counts
def _get_park_type_counts(self, queryset: QuerySet) -> Dict[str, int]:
"""Get counts for different park types based on operator names."""
return {
"disney": queryset.filter(operator__name__icontains="Disney").count(),
"universal": queryset.filter(operator__name__icontains="Universal").count(),
"six_flags": queryset.filter(operator__name__icontains="Six Flags").count(),
"cedar_fair": queryset.filter(
Q(operator__name__icontains="Cedar Fair")
| Q(operator__name__icontains="Cedar Point")
| Q(operator__name__icontains="Kings Island")
).count(),
}
def _get_top_operators(
self, queryset: QuerySet, limit: int = 10
) -> List[Dict[str, Any]]:
"""Get the top operators by number of parks."""
return list(
queryset.values("operator__name", "operator__id")
.annotate(park_count=Count("id"))
.filter(park_count__gt=0)
.order_by("-park_count")[:limit]
)
def _get_country_counts(
self, queryset: QuerySet, limit: int = 10
) -> List[Dict[str, Any]]:
"""Get countries with the most parks."""
return list(
queryset.filter(location__country__isnull=False)
.values("location__country")
.annotate(park_count=Count("id"))
.filter(park_count__gt=0)
.order_by("-park_count")[:limit]
)
def get_filter_suggestions(self, query: str) -> Dict[str, List[str]]:
"""
Get filter suggestions based on a search query.
Args:
query: Search query string
Returns:
Dictionary with suggestion categories
"""
cache_key = f"{self.cache_prefix}:suggestions:{query.lower()}"
cached_result = cache.get(cache_key)
if cached_result is not None:
return cached_result
suggestions = {
"parks": [],
"operators": [],
"locations": [],
}
if len(query) >= 2: # Only search for queries of 2+ characters
# Park name suggestions
park_names = Park.objects.filter(name__icontains=query).values_list(
"name", flat=True
)[:5]
suggestions["parks"] = list(park_names)
# Operator suggestions
operator_names = Company.objects.filter(
roles__contains=["OPERATOR"], name__icontains=query
).values_list("name", flat=True)[:5]
suggestions["operators"] = list(operator_names)
# Location suggestions (cities and countries)
locations = Park.objects.filter(
Q(location__city__icontains=query)
| Q(location__country__icontains=query)
).values_list("location__city", "location__country")[:5]
location_suggestions = []
for city, country in locations:
if city and city.lower().startswith(query.lower()):
location_suggestions.append(city)
elif country and country.lower().startswith(query.lower()):
location_suggestions.append(country)
suggestions["locations"] = list(set(location_suggestions))[:5]
# Cache suggestions for a shorter time
cache.set(cache_key, suggestions, 60) # 1 minute cache
return suggestions
def get_popular_filters(self) -> Dict[str, Any]:
"""
Get commonly used filter combinations and popular filter values.
Returns:
Dictionary containing popular filter configurations
"""
cache_key = f"{self.cache_prefix}:popular_filters"
cached_result = cache.get(cache_key)
if cached_result is not None:
return cached_result
base_qs = get_base_park_queryset()
popular_filters = {
"quick_filters": [
{
"label": "Disney Parks",
"filters": {"park_type": "disney"},
"count": base_qs.filter(operator__name__icontains="Disney").count(),
},
{
"label": "Parks with Coasters",
"filters": {"has_coasters": True},
"count": base_qs.filter(coaster_count__gt=0).count(),
},
{
"label": "Highly Rated",
"filters": {"min_rating": "4"},
"count": base_qs.filter(average_rating__gte=4.0).count(),
},
{
"label": "Major Parks",
"filters": {"big_parks_only": True},
"count": base_qs.filter(ride_count__gte=10).count(),
},
],
"recommended_sorts": [
{"value": "-average_rating", "label": "Highest Rated"},
{"value": "-coaster_count", "label": "Most Coasters"},
{"value": "name", "label": "A-Z"},
],
}
# Cache for longer since these don't change often
cache.set(cache_key, popular_filters, self.CACHE_TIMEOUT * 2)
return popular_filters
def clear_filter_cache(self) -> None:
"""Clear all cached filter data."""
# Simple cache clearing - delete known keys
cache_keys = [
f"{self.cache_prefix}:filter_counts",
f"{self.cache_prefix}:popular_filters",
]
for key in cache_keys:
cache.delete(key)
def get_filtered_queryset(self, filters: Dict[str, Any]) -> QuerySet: # noqa: C901
"""
Apply filters to get a filtered queryset with optimizations.
Args:
filters: Dictionary of filter parameters
Returns:
Filtered and optimized QuerySet
"""
queryset = (
get_base_park_queryset()
.select_related("operator", "property_owner", "location")
.prefetch_related("photos", "rides__manufacturer")
)
# Apply status filter
if filters.get("status"):
queryset = queryset.filter(status=filters["status"])
# Apply park type filter
if filters.get("park_type"):
queryset = self._apply_park_type_filter(queryset, filters["park_type"])
# Apply coaster filter
if filters.get("has_coasters"):
queryset = queryset.filter(coaster_count__gt=0)
# Apply rating filter
if filters.get("min_rating"):
try:
min_rating = float(filters["min_rating"])
queryset = queryset.filter(average_rating__gte=min_rating)
except (ValueError, TypeError):
pass
# Apply big parks filter
if filters.get("big_parks_only"):
queryset = queryset.filter(ride_count__gte=10)
# Apply search
if filters.get("search"):
search_query = filters["search"]
queryset = queryset.filter(
Q(name__icontains=search_query)
| Q(description__icontains=search_query)
| Q(location__city__icontains=search_query)
| Q(location__country__icontains=search_query)
)
# Apply location filters
if filters.get("country_filter"):
queryset = queryset.filter(
location__country__icontains=filters["country_filter"]
)
if filters.get("state_filter"):
queryset = queryset.filter(
location__state__icontains=filters["state_filter"]
)
# Apply ordering
if filters.get("ordering"):
queryset = queryset.order_by(filters["ordering"])
return queryset.distinct()
def _apply_park_type_filter(self, queryset: QuerySet, park_type: str) -> QuerySet:
"""Apply park type filter logic."""
type_filters = {
"disney": Q(operator__name__icontains="Disney"),
"universal": Q(operator__name__icontains="Universal"),
"six_flags": Q(operator__name__icontains="Six Flags"),
"cedar_fair": (
Q(operator__name__icontains="Cedar Fair")
| Q(operator__name__icontains="Cedar Point")
| Q(operator__name__icontains="Kings Island")
| Q(operator__name__icontains="Canada's Wonderland")
),
"independent": ~(
Q(operator__name__icontains="Disney")
| Q(operator__name__icontains="Universal")
| Q(operator__name__icontains="Six Flags")
| Q(operator__name__icontains="Cedar Fair")
| Q(operator__name__icontains="Cedar Point")
),
}
if park_type in type_filters:
return queryset.filter(type_filters[park_type])
return queryset

View File

@@ -1,4 +1,4 @@
/* Loading states */
/* Enhanced Loading states */
.htmx-request .htmx-indicator {
opacity: 1;
}
@@ -10,23 +10,140 @@
transition: opacity 200ms ease-in-out;
}
/* Loading pulse animation */
@keyframes loading-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.htmx-request {
animation: loading-pulse 1.5s ease-in-out infinite;
}
/* Results container transitions */
#park-results {
transition: opacity 200ms ease-in-out;
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.htmx-request #park-results {
opacity: 0.7;
transform: scale(0.98);
}
.htmx-settling #park-results {
opacity: 1;
transform: scale(1);
}
/* Filter UI Enhancements */
.quick-filter-btn {
@apply inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 ease-in-out;
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
@apply transform hover:scale-105 active:scale-95;
@apply border border-transparent;
}
.quick-filter-btn:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.filter-count {
@apply text-xs opacity-75 ml-1;
}
/* Filter Chips Styling */
.filter-chip {
@apply inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-800;
@apply dark:bg-blue-800 dark:text-blue-100 transition-all duration-200;
animation: slideIn 0.3s ease-out;
}
.filter-chip:hover {
@apply bg-blue-200 dark:bg-blue-700;
transform: translateY(-1px);
}
.filter-chip .remove-btn {
@apply ml-2 inline-flex items-center justify-center w-4 h-4;
@apply text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100;
@apply focus:outline-none transition-colors duration-150;
}
.filter-chip .remove-btn:hover {
transform: scale(1.1);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Enhanced Search Input */
.search-input {
@apply transition-all duration-200 ease-in-out;
}
.search-input:focus {
@apply ring-2 ring-blue-500 border-blue-500;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Enhanced Form Controls */
.filter-field select,
.filter-field input[type="text"],
.filter-field input[type="number"],
.filter-field input[type="search"],
.form-field-wrapper input,
.form-field-wrapper select {
@apply transition-all duration-200 ease-in-out;
@apply border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white;
@apply focus:border-blue-500 focus:ring-blue-500;
@apply rounded-md shadow-sm;
}
.filter-field select:focus,
.filter-field input:focus,
.form-field-wrapper input:focus,
.form-field-wrapper select:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.filter-field input[type="checkbox"],
.form-field-wrapper input[type="checkbox"] {
@apply rounded transition-colors duration-200;
@apply text-blue-600 focus:ring-blue-500;
@apply border-gray-300 dark:border-gray-600;
}
/* Enhanced Status Indicators */
.status-indicator {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
animation: fadeIn 0.3s ease-out;
}
.status-indicator.filtered {
@apply bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100;
}
.status-indicator.loading {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Grid/List transitions */
.park-card {
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
background-color: white;
border-radius: 0.5rem;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
}
@@ -36,8 +153,8 @@
flex-direction: column;
}
.park-card[data-view-mode="grid"]:hover {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* List view styles */
@@ -48,12 +165,14 @@
}
.park-card[data-view-mode="list"]:hover {
background-color: #f9fafb;
transform: translateX(4px);
}
/* Image containers */
.park-card .image-container {
position: relative;
overflow: hidden;
border-radius: 0.5rem;
}
.park-card[data-view-mode="grid"] .image-container {
aspect-ratio: 16 / 9;
@@ -73,23 +192,26 @@
min-width: 0; /* Enables text truncation in flex child */
}
/* Status badges */
/* Enhanced Status badges */
.park-card .status-badge {
transition: all 150ms ease-in-out;
transition: all 200ms ease-in-out;
border-radius: 9999px;
font-weight: 500;
}
.park-card:hover .status-badge {
transform: scale(1.05);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Images */
.park-card img {
transition: transform 200ms ease-in-out;
transition: transform 300ms ease-in-out;
object-fit: cover;
width: 100%;
height: 100%;
}
.park-card:hover img {
transform: scale(1.05);
transform: scale(1.08);
}
/* Placeholders for missing images */
@@ -97,6 +219,7 @@
background: linear-gradient(110deg, #ececec 8%, #f5f5f5 18%, #ececec 33%);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
border-radius: 0.5rem;
}
@keyframes shimmer {
@@ -105,7 +228,57 @@
}
}
/* Dark mode */
/* Enhanced No Results State */
.no-results {
@apply text-center py-12;
animation: fadeInUp 0.5s ease-out;
}
.no-results-icon {
@apply mx-auto w-24 h-24 text-gray-400 dark:text-gray-500 mb-6;
animation: float 3s ease-in-out infinite;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
/* Enhanced Buttons */
.btn-enhanced {
@apply transition-all duration-200 ease-in-out;
@apply transform hover:scale-105 active:scale-95;
@apply focus:outline-none focus:ring-2 focus:ring-offset-2;
}
.btn-enhanced:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* Tooltip Styles */
.tooltip {
@apply absolute z-50 px-2 py-1 text-xs font-medium text-white bg-gray-900 rounded shadow-lg;
@apply dark:bg-gray-700 dark:text-gray-200;
animation: tooltipFadeIn 0.2s ease-out;
}
@keyframes tooltipFadeIn {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
/* Enhanced Dark mode */
@media (prefers-color-scheme: dark) {
.park-card {
background-color: #1f2937;
@@ -128,7 +301,111 @@
background: linear-gradient(110deg, #2d3748 8%, #374151 18%, #2d3748 33%);
}
.search-input {
@apply dark:bg-gray-700 dark:border-gray-600 dark:text-white;
}
.quick-filter-btn:not(.active) {
@apply dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600;
}
/* Enhanced filter panel styling */
.filter-container {
@apply dark:text-gray-200;
}
.filter-field label {
@apply dark:text-gray-300;
}
}
/* Additional enhancements for better visual hierarchy */
.filter-container h3 {
@apply font-semibold tracking-wide;
}
.filter-field {
@apply mb-4;
}
.filter-field label {
@apply font-medium text-sm;
}
/* Status badge improvements */
.status-badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
@apply transition-all duration-200;
}
/* Loading state improvements */
.htmx-request .filter-container {
@apply pointer-events-none;
}
.htmx-request .quick-filter-btn {
@apply opacity-75;
}
/* Mobile Responsive Enhancements */
@media (max-width: 768px) {
.quick-filter-btn {
@apply text-xs px-2 py-1;
}
.filter-chip {
@apply text-xs px-2 py-1;
}
.park-card[data-view-mode="grid"]:hover {
transform: translateY(-2px);
}
.park-card[data-view-mode="list"]:hover {
background-color: #374151;
transform: none;
}
}
/* Accessibility Enhancements */
@media (prefers-reduced-motion: reduce) {
.park-card,
.quick-filter-btn,
.filter-chip,
.btn-enhanced {
transition: none;
animation: none;
}
.park-card:hover {
transform: none;
}
.no-results-icon {
animation: none;
}
}
/* Focus States for Keyboard Navigation */
.park-card:focus-within {
@apply ring-2 ring-blue-500 ring-offset-2;
}
.quick-filter-btn:focus,
.filter-chip .remove-btn:focus {
@apply ring-2 ring-blue-500 ring-offset-2;
}
/* High Contrast Mode Support */
@media (prefers-contrast: high) {
.park-card {
border-width: 2px;
}
.filter-chip {
border: 2px solid currentColor;
}
.quick-filter-btn {
border: 2px solid currentColor;
}
}

View File

@@ -1,5 +1,50 @@
// Handle view mode persistence across HTMX requests
document.addEventListener('htmx:configRequest', function(evt) {
/**
* Enhanced Parks Search and Filter Management
* Provides comprehensive UX improvements for the parks listing page
*/
class ParkSearchManager {
constructor() {
this.debounceTimers = new Map();
this.filterState = new Map();
this.requestCount = 0;
this.lastRequestTime = 0;
this.init();
}
init() {
this.setupEventListeners();
this.initializeLazyLoading();
this.setupKeyboardNavigation();
this.restoreFilterState();
this.setupPerformanceOptimizations();
}
setupEventListeners() {
// Enhanced HTMX request handling
document.addEventListener('htmx:configRequest', (evt) => this.handleConfigRequest(evt));
document.addEventListener('htmx:beforeRequest', (evt) => this.handleBeforeRequest(evt));
document.addEventListener('htmx:afterRequest', (evt) => this.handleAfterRequest(evt));
document.addEventListener('htmx:responseError', (evt) => this.handleResponseError(evt));
document.addEventListener('htmx:historyRestore', (evt) => this.handleHistoryRestore(evt));
document.addEventListener('htmx:afterSwap', (evt) => this.handleAfterSwap(evt));
// Enhanced form interactions
document.addEventListener('input', (evt) => this.handleInput(evt));
document.addEventListener('change', (evt) => this.handleChange(evt));
document.addEventListener('focus', (evt) => this.handleFocus(evt));
document.addEventListener('blur', (evt) => this.handleBlur(evt));
// Search suggestions
document.addEventListener('keydown', (evt) => this.handleKeydown(evt));
// Window events
window.addEventListener('beforeunload', () => this.saveFilterState());
window.addEventListener('resize', this.debounce(() => this.handleResize(), 250));
}
handleConfigRequest(evt) {
// Preserve view mode
const parkResults = document.getElementById('park-results');
if (parkResults) {
@@ -10,29 +55,62 @@ document.addEventListener('htmx:configRequest', function(evt) {
}
// Preserve search terms
const searchInput = document.getElementById('search');
const searchInput = document.querySelector('input[name="search"]');
if (searchInput && searchInput.value) {
evt.detail.parameters['search'] = searchInput.value;
}
});
// Handle loading states
document.addEventListener('htmx:beforeRequest', function(evt) {
// Add request tracking
evt.detail.parameters['_req_id'] = ++this.requestCount;
this.lastRequestTime = Date.now();
}
handleBeforeRequest(evt) {
const target = evt.detail.target;
if (target) {
target.classList.add('htmx-requesting');
this.showLoadingIndicator(target);
}
});
document.addEventListener('htmx:afterRequest', function(evt) {
// Disable form elements during request
this.toggleFormElements(false);
// Track request analytics
this.trackFilterUsage(evt);
}
handleAfterRequest(evt) {
const target = evt.detail.target;
if (target) {
target.classList.remove('htmx-requesting');
this.hideLoadingIndicator(target);
}
});
// Handle history navigation
document.addEventListener('htmx:historyRestore', function(evt) {
// Re-enable form elements
this.toggleFormElements(true);
// Handle response timing
const responseTime = Date.now() - this.lastRequestTime;
if (responseTime > 3000) {
this.showPerformanceWarning();
}
}
handleResponseError(evt) {
this.hideLoadingIndicator(evt.detail.target);
this.toggleFormElements(true);
this.showErrorMessage('Failed to load results. Please try again.');
}
handleAfterSwap(evt) {
if (evt.detail.target.id === 'results-container') {
this.initializeLazyLoading(evt.detail.target);
this.updateResultsInfo(evt.detail.target);
this.animateResults(evt.detail.target);
}
}
handleHistoryRestore(evt) {
const parkResults = document.getElementById('park-results');
if (parkResults && evt.detail.path) {
const url = new URL(evt.detail.path, window.location.origin);
@@ -41,21 +119,140 @@ document.addEventListener('htmx:historyRestore', function(evt) {
parkResults.setAttribute('data-view-mode', viewMode);
}
}
});
// Initialize lazy loading for images
function initializeLazyLoading(container) {
// Restore filter state from URL
this.restoreFiltersFromURL(evt.detail.path);
}
handleInput(evt) {
if (evt.target.type === 'search' || evt.target.type === 'text') {
this.debounceInput(evt.target);
}
}
handleChange(evt) {
if (evt.target.closest('#filter-form')) {
this.updateFilterState();
this.saveFilterState();
}
}
handleFocus(evt) {
if (evt.target.type === 'search') {
this.highlightSearchSuggestions(evt.target);
}
}
handleBlur(evt) {
if (evt.target.type === 'search') {
// Delay hiding suggestions to allow for clicks
setTimeout(() => this.hideSearchSuggestions(), 150);
}
}
handleKeydown(evt) {
if (evt.target.type === 'search') {
this.handleSearchKeyboard(evt);
}
}
handleResize() {
// Responsive adjustments
this.adjustLayoutForViewport();
}
debounceInput(input) {
const key = input.name || input.id;
if (this.debounceTimers.has(key)) {
clearTimeout(this.debounceTimers.get(key));
}
const delay = input.type === 'search' ? 300 : 500;
const timer = setTimeout(() => {
if (input.form) {
htmx.trigger(input.form, 'change');
}
this.debounceTimers.delete(key);
}, delay);
this.debounceTimers.set(key, timer);
}
handleSearchKeyboard(evt) {
const suggestions = document.getElementById('search-results');
if (!suggestions) return;
const items = suggestions.querySelectorAll('[role="option"]');
let activeIndex = Array.from(items).findIndex(item =>
item.classList.contains('active') || item.classList.contains('highlighted')
);
switch (evt.key) {
case 'ArrowDown':
evt.preventDefault();
activeIndex = Math.min(activeIndex + 1, items.length - 1);
this.highlightSuggestion(items, activeIndex);
break;
case 'ArrowUp':
evt.preventDefault();
activeIndex = Math.max(activeIndex - 1, -1);
this.highlightSuggestion(items, activeIndex);
break;
case 'Enter':
if (activeIndex >= 0 && items[activeIndex]) {
evt.preventDefault();
items[activeIndex].click();
}
break;
case 'Escape':
this.hideSearchSuggestions();
evt.target.blur();
break;
}
}
highlightSuggestion(items, activeIndex) {
items.forEach((item, index) => {
item.classList.toggle('active', index === activeIndex);
item.classList.toggle('highlighted', index === activeIndex);
});
}
highlightSearchSuggestions(input) {
const suggestions = document.getElementById('search-results');
if (suggestions && input.value) {
suggestions.style.display = 'block';
}
}
hideSearchSuggestions() {
const suggestions = document.getElementById('search-results');
if (suggestions) {
suggestions.style.display = 'none';
}
}
initializeLazyLoading(container = document) {
if (!('IntersectionObserver' in window)) return;
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
img.classList.add('loaded');
imageObserver.unobserve(img);
}
}
});
}, {
threshold: 0.1,
rootMargin: '50px'
});
container.querySelectorAll('img[data-src]').forEach(img => {
@@ -63,7 +260,291 @@ function initializeLazyLoading(container) {
});
}
// Initialize lazy loading after HTMX content swaps
document.addEventListener('htmx:afterSwap', function(evt) {
initializeLazyLoading(evt.detail.target);
setupKeyboardNavigation() {
// Tab navigation for filter cards
document.addEventListener('keydown', (evt) => {
if (evt.key === 'Tab' && evt.target.closest('.park-card')) {
this.handleCardNavigation(evt);
}
});
}
setupPerformanceOptimizations() {
// Prefetch next page if pagination exists
this.setupPrefetching();
// Optimize scroll performance
this.setupScrollOptimization();
}
setupPrefetching() {
const nextPageLink = document.querySelector('a[rel="next"]');
if (nextPageLink && 'IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.prefetchPage(nextPageLink.href);
observer.unobserve(entry.target);
}
});
});
const trigger = document.querySelector('.pagination');
if (trigger) {
observer.observe(trigger);
}
}
}
setupScrollOptimization() {
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
this.handleScroll();
ticking = false;
});
ticking = true;
}
});
}
handleScroll() {
// Show/hide back to top button
const backToTop = document.getElementById('back-to-top');
if (backToTop) {
backToTop.style.display = window.scrollY > 500 ? 'block' : 'none';
}
}
prefetchPage(url) {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
document.head.appendChild(link);
}
showLoadingIndicator(target) {
// Add subtle loading animation
target.style.transition = 'opacity 0.3s ease-in-out';
target.style.opacity = '0.7';
}
hideLoadingIndicator(target) {
target.style.opacity = '1';
}
toggleFormElements(enabled) {
const form = document.getElementById('filter-form');
if (form) {
const elements = form.querySelectorAll('input, select, button');
elements.forEach(el => {
el.disabled = !enabled;
});
}
}
updateFilterState() {
const form = document.getElementById('filter-form');
if (!form) return;
const formData = new FormData(form);
this.filterState.clear();
for (const [key, value] of formData.entries()) {
if (value && value !== '') {
this.filterState.set(key, value);
}
}
}
saveFilterState() {
try {
const state = Object.fromEntries(this.filterState);
localStorage.setItem('parkFilters', JSON.stringify(state));
} catch (e) {
console.warn('Failed to save filter state:', e);
}
}
restoreFilterState() {
try {
const saved = localStorage.getItem('parkFilters');
if (saved) {
const state = JSON.parse(saved);
this.applyFilterState(state);
}
} catch (e) {
console.warn('Failed to restore filter state:', e);
}
}
restoreFiltersFromURL(path) {
const url = new URL(path, window.location.origin);
const params = new URLSearchParams(url.search);
const form = document.getElementById('filter-form');
if (!form) return;
// Clear existing values
form.reset();
// Apply URL parameters
for (const [key, value] of params.entries()) {
const input = form.querySelector(`[name="${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value === 'on' || value === 'true';
} else {
input.value = value;
}
}
}
}
applyFilterState(state) {
const form = document.getElementById('filter-form');
if (!form) return;
Object.entries(state).forEach(([key, value]) => {
const input = form.querySelector(`[name="${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value === 'on' || value === 'true';
} else {
input.value = value;
}
}
});
}
updateResultsInfo(container) {
// Update any result count displays
const countElements = container.querySelectorAll('[data-result-count]');
countElements.forEach(el => {
const count = container.querySelectorAll('.park-card').length;
el.textContent = count;
});
}
animateResults(container) {
// Subtle animation for new results
const cards = container.querySelectorAll('.park-card');
cards.forEach((card, index) => {
card.style.opacity = '0';
card.style.transform = 'translateY(20px)';
setTimeout(() => {
card.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out';
card.style.opacity = '1';
card.style.transform = 'translateY(0)';
}, index * 50);
});
}
adjustLayoutForViewport() {
const viewport = window.innerWidth;
// Adjust grid columns based on viewport
const grid = document.querySelector('.park-card-grid');
if (grid) {
if (viewport < 768) {
grid.style.gridTemplateColumns = '1fr';
} else if (viewport < 1024) {
grid.style.gridTemplateColumns = 'repeat(2, 1fr)';
} else {
grid.style.gridTemplateColumns = 'repeat(3, 1fr)';
}
}
}
trackFilterUsage(evt) {
// Track which filters are being used for analytics
if (window.gtag) {
const formData = new FormData(evt.detail.elt);
const activeFilters = [];
for (const [key, value] of formData.entries()) {
if (value && value !== '' && key !== 'csrfmiddlewaretoken') {
activeFilters.push(key);
}
}
window.gtag('event', 'filter_usage', {
'filters_used': activeFilters.join(','),
'filter_count': activeFilters.length
});
}
}
showPerformanceWarning() {
// Show a subtle warning for slow responses
const warning = document.createElement('div');
warning.className = 'fixed top-4 right-4 bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded z-50';
warning.innerHTML = `
<span class="block sm:inline">Search is taking longer than expected...</span>
<span class="absolute top-0 bottom-0 right-0 px-4 py-3 cursor-pointer" onclick="this.parentElement.remove()">
<svg class="fill-current h-6 w-6 text-yellow-500" role="button" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651 3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1.2 1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1 1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1.698z"/>
</svg>
</span>
`;
document.body.appendChild(warning);
setTimeout(() => {
if (warning.parentElement) {
warning.remove();
}
}, 5000);
}
showErrorMessage(message) {
// Show error message with retry option
const errorDiv = document.createElement('div');
errorDiv.className = 'fixed top-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50';
errorDiv.innerHTML = `
<span class="block sm:inline">${message}</span>
<button class="ml-4 bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded text-sm" onclick="location.reload()">
Retry
</button>
<span class="absolute top-0 bottom-0 right-0 px-4 py-3 cursor-pointer" onclick="this.parentElement.remove()">
<svg class="fill-current h-6 w-6 text-red-500" role="button" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651 3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1.2 1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1 1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1.698z"/>
</svg>
</span>
`;
document.body.appendChild(errorDiv);
setTimeout(() => {
if (errorDiv.parentElement) {
errorDiv.remove();
}
}, 10000);
}
// Utility method for debouncing
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
}
// Initialize the enhanced search manager
document.addEventListener('DOMContentLoaded', () => {
window.parkSearchManager = new ParkSearchManager();
});
// Export for potential module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = ParkSearchManager;
}

View File

@@ -1,20 +1,19 @@
{% extends "search/layouts/filtered_list.html" %}
{% extends "core/search/layouts/filtered_list.html" %}
{% load static %}
{% load filter_utils %}
{% block title %}Parks - ThrillWiki{% endblock %}
{% block list_actions %}
<div class="flex justify-between items-center mb-6">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<div class="flex items-center space-x-4">
<h1 class="text-2xl font-bold text-gray-900">Parks</h1>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Parks</h1>
<fieldset class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
<fieldset class="flex items-center space-x-2 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<legend class="sr-only">View mode selection</legend>
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
hx-target="#results-container"
hx-push-url="true"
class="p-2 rounded transition-colors duration-200 {% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}bg-white shadow-sm{% endif %}"
class="p-2 rounded transition-colors duration-200 {% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}bg-white dark:bg-gray-600 shadow-sm text-gray-900 dark:text-white{% else %}text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300{% endif %}"
aria-label="Grid view"
aria-pressed="{% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}true{% else %}false{% endif %}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -24,7 +23,7 @@
<button hx-get="{% url 'parks:park_list' %}?view_mode=list{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
hx-target="#results-container"
hx-push-url="true"
class="p-2 rounded transition-colors duration-200 {% if request.GET.view_mode == 'list' %}bg-white shadow-sm{% endif %}"
class="p-2 rounded transition-colors duration-200 {% if request.GET.view_mode == 'list' %}bg-white dark:bg-gray-600 shadow-sm text-gray-900 dark:text-white{% else %}text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300{% endif %}"
aria-label="List view"
aria-pressed="{% if request.GET.view_mode == 'list' %}true{% else %}false{% endif %}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -36,8 +35,11 @@
{% if user.is_authenticated %}
<a href="{% url 'parks:park_create' %}"
class="btn btn-primary"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200"
data-testid="add-park-button">
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
Add Park
</a>
{% endif %}
@@ -45,8 +47,10 @@
{% endblock %}
{% block filter_section %}
<div class="mb-6">
<div class="max-w-3xl mx-auto relative mb-8">
<!-- DEBUG: park_list.html filter_section block is being rendered - timestamp: 2025-08-21 -->
<div class="mb-6" x-data="parkListManager()" x-init="init()">
{# Enhanced Search Section #}
<div class="relative mb-8">
<div class="w-full relative"
x-data="{ query: '', selectedId: null }"
@search-selected.window="
@@ -62,15 +66,31 @@
hx-indicator="#search-indicator"
x-ref="searchForm">
<div class="relative">
<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" 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"/>
</svg>
</div>
<input type="search"
name="search"
placeholder="Search parks..."
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search parks by name, location, or description..."
class="w-full pl-10 pr-12 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 focus:ring-blue-500 transition-colors duration-200"
aria-label="Search parks"
aria-controls="search-results"
:aria-expanded="query !== ''"
x-model="query"
@keydown.escape="query = ''">
@keydown.escape="query = ''"
@focus="$event.target.select()">
<!-- Clear search button -->
<button type="button"
x-show="query"
@click="query = ''; $refs.searchForm.querySelector('input').value = ''; $refs.filterForm.submit();"
class="absolute inset-y-0 right-8 flex items-center pr-1 text-gray-400 hover:text-gray-600 focus:outline-none">
<svg class="h-4 w-4" 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>
<!-- Loading indicator -->
<div id="search-indicator"
@@ -87,35 +107,412 @@
</form>
<div id="search-results"
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg"
role="listbox">
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-600"
role="listbox"
x-show="query"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95">
<!-- Search suggestions will be loaded here -->
</div>
</div>
</div>
<div class="bg-white shadow sm:rounded-lg">
{# Active Filter Chips Section #}
<div id="active-filters-section"
x-show="hasActiveFilters"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
class="mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-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</h3>
<button type="button"
@click="clearAllFilters()"
class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 font-medium focus:outline-none focus:underline">
Clear All
</button>
</div>
<div class="flex flex-wrap gap-2" id="filter-chips-container">
<!-- Filter chips will be populated by JavaScript -->
</div>
</div>
</div>
{# Filter Panel #}
<div class="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">Filters</h3>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">Filters</h3>
<button type="button"
x-data="{ collapsed: false }"
@click="collapsed = !collapsed; toggleFilterCollapse()"
class="lg:hidden text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 font-medium focus:outline-none">
<span x-text="collapsed ? 'Show Filters' : 'Hide Filters'"></span>
</button>
</div>
<form id="filter-form"
x-ref="filterForm"
hx-get="{% url 'parks:park_list' %}"
hx-target="#results-container"
hx-push-url="true"
hx-trigger="change, submit"
class="mt-4">
hx-indicator="#main-loading-indicator"
class="mt-4"
@htmx:beforeRequest="onFilterRequest()"
@htmx:afterRequest="onFilterResponse($event)">
<input type="hidden" name="search" value="{{ request.GET.search }}">
{% include "search/components/filter_form.html" with filter=filter %}
{% include "core/search/components/filter_form.html" with filter=filter %}
</form>
</div>
</div>
</div>
{# Main Loading Indicator #}
<div id="main-loading-indicator" class="htmx-indicator fixed top-4 left-1/2 transform -translate-x-1/2 z-50">
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white px-6 py-3 rounded-lg shadow-lg flex items-center backdrop-blur-sm">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" xmlns="http://www.w3.org/2000/svg" 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-sm font-medium">Updating results...</span>
</div>
</div>
<script>
function parkListManager() {
return {
hasActiveFilters: false,
filterCollapsed: false,
lastResultCount: 0,
init() {
this.updateActiveFilters();
this.setupFilterChips();
// Listen for form changes to update filter chips
document.addEventListener('change', (e) => {
if (e.target.closest('#filter-form')) {
setTimeout(() => this.updateActiveFilters(), 100);
}
});
// Listen for HTMX responses to update result counts
document.addEventListener('htmx:afterSwap', (e) => {
if (e.detail.target.id === 'results-container') {
this.updateResultInfo();
}
});
},
updateActiveFilters() {
const form = document.getElementById('filter-form');
if (!form) return;
const activeFilters = [];
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
if (this.isFilterActive(input)) {
activeFilters.push(this.createFilterChip(input));
}
});
this.hasActiveFilters = activeFilters.length > 0;
this.renderFilterChips(activeFilters);
},
isFilterActive(input) {
if (!input.name || input.type === 'hidden') return false;
if (input.type === 'checkbox' || input.type === 'radio') {
return input.checked;
}
return input.value && input.value !== '' && input.value !== 'all' && input.value !== '0';
},
createFilterChip(input) {
let label = input.name;
let value = input.value;
// Get human readable label from associated label element
const labelElement = document.querySelector(`label[for="${input.id}"]`);
if (labelElement) {
label = labelElement.textContent.trim();
}
// Format value for display
if (input.type === 'checkbox') {
value = 'Yes';
} else if (input.tagName === 'SELECT') {
const selectedOption = input.querySelector(`option[value="${input.value}"]`);
if (selectedOption) {
value = selectedOption.textContent;
}
}
return {
name: input.name,
label: label,
value: value,
displayText: `${label}: ${value}`
};
},
renderFilterChips(chips) {
const container = document.getElementById('filter-chips-container');
if (!container) return;
container.innerHTML = chips.map(chip => `
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100">
${chip.displayText}
<button type="button"
onclick="removeFilter('${chip.name}')"
class="ml-2 inline-flex items-center justify-center w-4 h-4 text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100 focus:outline-none"
aria-label="Remove ${chip.label} 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>
</span>
`).join('');
},
clearAllFilters() {
const form = document.getElementById('filter-form');
if (!form) return;
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
if (input.type === 'checkbox' || input.type === 'radio') {
input.checked = false;
} else if (input.type !== 'hidden') {
input.value = '';
}
});
this.hasActiveFilters = false;
htmx.trigger(form, 'submit');
},
toggleFilterCollapse() {
this.filterCollapsed = !this.filterCollapsed;
},
onFilterRequest() {
// Add loading state to results
const resultsContainer = document.getElementById('results-container');
if (resultsContainer) {
resultsContainer.style.opacity = '0.6';
resultsContainer.style.pointerEvents = 'none';
}
},
onFilterResponse(event) {
// Remove loading state
const resultsContainer = document.getElementById('results-container');
if (resultsContainer) {
resultsContainer.style.opacity = '1';
resultsContainer.style.pointerEvents = 'auto';
}
// Update active filters after response
setTimeout(() => this.updateActiveFilters(), 100);
},
updateResultInfo() {
// This would update any result count information
// Implementation depends on how results are structured
}
}
}
// Global function to remove individual filters
function removeFilter(filterName) {
const form = document.getElementById('filter-form');
if (!form) return;
const input = form.querySelector(`[name="${filterName}"]`);
if (input) {
if (input.type === 'checkbox' || input.type === 'radio') {
input.checked = false;
} else {
input.value = '';
}
htmx.trigger(form, 'submit');
}
}
</script>
{% endblock %}
{% block results_list %}
<div id="park-results"
class="bg-white rounded-lg shadow"
class="bg-white dark:bg-gray-800 rounded-lg shadow transition-all duration-200"
data-view-mode="{{ view_mode|default:'grid' }}">
{% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
{# Results Header with Count and Sort #}
<div class="border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex items-center space-x-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Parks
{% if parks %}
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">
({{ parks|length }}{% if parks.has_other_pages %} of {{ parks.paginator.count }}{% endif %} found)
</span>
{% endif %}
</h2>
{# Results status indicator #}
{% if request.GET.search or request.GET.park_type or request.GET.has_coasters or request.GET.min_rating or request.GET.big_parks_only %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100">
Filtered
</span>
{% endif %}
</div>
{# Sort Options #}
<div class="flex items-center space-x-2">
<label for="sort-select" class="text-sm font-medium text-gray-700 dark:text-gray-300">Sort by:</label>
<select id="sort-select"
name="ordering"
form="filter-form"
class="text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-md focus:border-blue-500 focus:ring-blue-500"
onchange="document.getElementById('filter-form').submit()">
<option value="">Default</option>
<option value="name" {% if request.GET.ordering == 'name' %}selected{% endif %}>Name (A-Z)</option>
<option value="-name" {% if request.GET.ordering == '-name' %}selected{% endif %}>Name (Z-A)</option>
<option value="-average_rating" {% if request.GET.ordering == '-average_rating' %}selected{% endif %}>Highest Rated</option>
<option value="-coaster_count" {% if request.GET.ordering == '-coaster_count' %}selected{% endif %}>Most Coasters</option>
<option value="-ride_count" {% if request.GET.ordering == '-ride_count' %}selected{% endif %}>Most Rides</option>
</select>
</div>
</div>
</div>
{# Results Content #}
<div class="p-6">
{% if parks %}
{% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
{% else %}
{# No Results Found #}
<div class="text-center py-12">
<div class="mx-auto w-24 h-24 text-gray-400 dark:text-gray-500 mb-6">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-full h-full">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No parks found</h3>
{% if request.GET.search %}
<p class="text-gray-500 dark:text-gray-400 mb-6">
No parks match your search for <span class="font-semibold">"{{ request.GET.search }}"</span>.
<br>Try searching with different keywords or check your spelling.
</p>
{% elif request.GET.park_type or request.GET.has_coasters or request.GET.min_rating or request.GET.big_parks_only %}
<p class="text-gray-500 dark:text-gray-400 mb-6">
No parks match your current filter criteria.
<br>Try adjusting your filters or removing some restrictions.
</p>
{% else %}
<p class="text-gray-500 dark:text-gray-400 mb-6">
No parks are currently available in the database.
</p>
{% endif %}
<div class="space-y-3">
{% if request.GET.search or request.GET.park_type or request.GET.has_coasters or request.GET.min_rating or request.GET.big_parks_only %}
<button type="button"
onclick="clearAllFilters()"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200">
<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"></path>
</svg>
Clear All Filters
</button>
<br>
<a href="{% url 'parks:park_list' %}"
class="inline-flex items-center text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 font-medium">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
View all parks
</a>
{% endif %}
{% if user.is_authenticated %}
<div class="pt-2">
<a href="{% url 'parks:park_create' %}"
class="inline-flex items-center text-sm text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 font-medium">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Add a new park
</a>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
<script>
// Enhanced clear all filters function
function clearAllFilters() {
const form = document.getElementById('filter-form');
if (!form) return;
// Clear all form inputs
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
if (input.type === 'checkbox' || input.type === 'radio') {
input.checked = false;
} else if (input.type !== 'hidden') {
input.value = '';
}
});
// Clear search input as well
const searchInput = document.querySelector('input[name="search"]');
if (searchInput) {
searchInput.value = '';
}
// Submit form to reload without filters
htmx.trigger(form, 'submit');
}
// Smooth scroll to results after filter changes
document.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'results-container') {
// Add a subtle scroll to results with a small delay for content to settle
setTimeout(() => {
const resultsElement = document.getElementById('park-results');
if (resultsElement) {
resultsElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
});
}
}, 100);
// Flash effect to indicate content update
const resultsContainer = event.detail.target;
resultsContainer.style.opacity = '0.8';
setTimeout(() => {
resultsContainer.style.opacity = '1';
}, 150);
}
});
</script>
{% endblock %}

View File

@@ -1,9 +1,8 @@
{% load static %}
{% load filter_utils %}
{% if error %}
<div class="p-4" data-testid="park-list-error">
<div class="inline-flex items-center px-4 py-2 rounded-md bg-red-50 text-red-700">
<div class="inline-flex items-center px-4 py-2 rounded-md bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
@@ -13,40 +12,77 @@
{% else %}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for park in object_list|default:parks %}
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<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">
<div class="overflow-hidden transition-all duration-300 transform bg-white rounded-lg shadow-sm border border-gray-200 dark:bg-gray-800 dark:border-gray-700 hover:-translate-y-2 hover:shadow-lg">
<div class="p-6">
<h2 class="mb-3 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 transition-colors duration-200">
{{ park.name }}
</a>
</h2>
<div class="flex flex-wrap gap-2">
<span class="status-badge status-{{ park.status|lower }}">
<div class="flex flex-wrap gap-2 mb-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{% if park.status == 'operating' %}bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400
{% elif park.status == 'closed' %}bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400
{% elif park.status == 'seasonal' %}bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{{ park.get_status_display }}
</span>
</div>
{% if park.owner %}
<div class="mt-4 text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
<a href="{% url 'companies:company_detail' park.owner.slug %}">
{{ park.owner.name }}
</a>
{% if park.operator %}
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ park.operator.name }}
</div>
{% endif %}
{% if park.description %}
<p class="mt-3 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ park.description|truncatewords:20 }}
</p>
{% endif %}
{% if park.ride_count or park.coaster_count %}
<div class="mt-4 flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400">
{% if park.ride_count %}
<span class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
{{ park.ride_count }} rides
</span>
{% endif %}
{% if park.coaster_count %}
<span class="flex items-center">
<svg class="w-4 h-4 mr-1" 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>
{{ park.coaster_count }} coasters
</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% empty %}
<div class="col-span-full p-4 text-sm text-gray-500 text-center" data-testid="no-parks-found">
<div class="col-span-full p-8 text-center" data-testid="no-parks-found">
<div class="mx-auto w-16 h-16 text-gray-400 dark:text-gray-500 mb-4">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-full h-full">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No parks found</h3>
<div class="text-sm text-gray-500 dark:text-gray-400">
{% if search_query %}
No parks found matching "{{ search_query }}". Try adjusting your search terms.
<p class="mb-4">No parks found matching "{{ search_query }}". Try adjusting your search terms.</p>
{% else %}
No parks found matching your criteria. Try adjusting your filters.
<p class="mb-4">No parks found matching your criteria. Try adjusting your filters.</p>
{% endif %}
{% if user.is_authenticated %}
You can also <a href="{% url 'parks:park_create' %}" class="text-blue-600 hover:underline">add a new park</a>.
<p>You can also <a href="{% url 'parks:park_create' %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 font-medium">add a new park</a>.</p>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}

View File

@@ -1,4 +1,3 @@
{% load filter_utils %}
{% if suggestions %}
<div id="search-suggestions-results"
class="w-full bg-white rounded-md shadow-lg border border-gray-200 max-h-96 overflow-y-auto"

View File

@@ -12,6 +12,7 @@ from core.views.views import SlugRedirectMixin
from .filters import ParkFilter
from .forms import ParkForm
from .models import Park, ParkArea, ParkReview as Review
from .services import ParkFilterService
from django.http import (
HttpResponseRedirect,
HttpResponse,
@@ -29,7 +30,7 @@ from decimal import InvalidOperation
from django.views.generic import DetailView, ListView, CreateView, UpdateView
import requests
from decimal import Decimal, ROUND_DOWN
from typing import Any, Optional, cast, Literal
from typing import Any, Optional, cast, Literal, Dict
# Constants
PARK_DETAIL_URL = "parks:park_detail"
@@ -44,7 +45,7 @@ ViewMode = Literal["grid", "list"]
def normalize_osm_result(result: dict) -> dict:
"""Normalize OpenStreetMap result to a consistent format with enhanced address details"""
"""Normalize OpenStreetMap result to a consistent format with enhanced address details""" # noqa: E501
from .location_utils import get_english_name, normalize_coordinate
# Get address details
@@ -229,6 +230,10 @@ class ParkListView(HTMXFilterableMixin, ListView):
filter_class = ParkFilter
paginate_by = 20
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.filter_service = ParkFilterService()
def get_template_names(self) -> list[str]:
"""Return park_list_item.html for HTMX requests"""
if self.request.htmx:
@@ -240,35 +245,60 @@ class ParkListView(HTMXFilterableMixin, ListView):
return get_view_mode(self.request)
def get_queryset(self) -> QuerySet[Park]:
"""Get base queryset with annotations and apply filters"""
"""Get optimized queryset with filter service"""
try:
queryset = get_base_park_queryset()
# Use filter service for optimized filtering
filter_params = dict(self.request.GET.items())
queryset = self.filter_service.get_filtered_queryset(filter_params)
# Also create filterset for form rendering
self.filterset = self.filter_class(self.request.GET, queryset=queryset)
return self.filterset.qs
except Exception as e:
messages.error(self.request, f"Error loading parks: {str(e)}")
queryset = self.model.objects.none()
# Always initialize filterset, even if queryset failed
self.filterset = self.filter_class(self.request.GET, queryset=queryset)
return self.filterset.qs
return queryset
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
"""Add view_mode and other context data"""
"""Add enhanced context with filter stats and suggestions"""
try:
# Initialize filterset even if queryset fails
# Initialize filterset if not exists
if not hasattr(self, "filterset"):
self.filterset = self.filter_class(
self.request.GET, queryset=self.model.objects.none()
)
context = super().get_context_data(**kwargs)
# Add filter service data
filter_counts = self.filter_service.get_filter_counts()
popular_filters = self.filter_service.get_popular_filters()
context.update(
{
"view_mode": self.get_view_mode(),
"is_search": bool(self.request.GET.get("search")),
"search_query": self.request.GET.get("search", ""),
"filter_counts": filter_counts,
"popular_filters": popular_filters,
"total_results": (
context.get("paginator").count
if context.get("paginator")
else 0
),
}
)
# Add filter suggestions for search queries
search_query = self.request.GET.get("search", "")
if search_query:
context["filter_suggestions"] = (
self.filter_service.get_filter_suggestions(search_query)
)
return context
except Exception as e:
messages.error(self.request, f"Error applying filters: {str(e)}")
# Ensure filterset exists in error case
@@ -284,6 +314,108 @@ class ParkListView(HTMXFilterableMixin, ListView):
"search_query": self.request.GET.get("search", ""),
}
def _get_clean_filter_params(self) -> Dict[str, Any]:
"""Extract and clean filter parameters from request."""
filter_params = {}
# Define valid filter fields
valid_filters = {
"status",
"operator",
"park_type",
"has_coasters",
"min_rating",
"big_parks_only",
"ordering",
"search",
}
for param, value in self.request.GET.items():
if param in valid_filters and value:
# Skip pagination parameter
if param == "page":
continue
# Clean and validate the value
filter_params[param] = self._clean_filter_value(param, value)
return {k: v for k, v in filter_params.items() if v is not None}
def _clean_filter_value(self, param: str, value: str) -> Optional[Any]:
"""Clean and validate a single filter value."""
if param in ("has_coasters", "big_parks_only"):
# Boolean filters
return value.lower() in ("true", "1", "yes", "on")
elif param == "min_rating":
# Numeric filter
try:
rating = float(value)
if 0 <= rating <= 5:
return str(rating)
except (ValueError, TypeError):
pass # Skip invalid ratings
return None
elif param == "search":
# Search filter
clean_search = value.strip()
return clean_search if clean_search else None
else:
# String filters
return value.strip()
def _build_filter_query_string(self, filter_params: Dict[str, Any]) -> str:
"""Build query string from filter parameters."""
from urllib.parse import urlencode
# Convert boolean values to strings for URL
url_params = {}
for key, value in filter_params.items():
if isinstance(value, bool):
url_params[key] = "true" if value else "false"
else:
url_params[key] = str(value)
return urlencode(url_params)
def _get_pagination_urls(
self, page_obj, filter_params: Dict[str, Any]
) -> Dict[str, str]:
"""Generate pagination URLs that preserve filter state."""
base_query = self._build_filter_query_string(filter_params)
pagination_urls = {}
if page_obj.has_previous():
prev_params = (
f"{base_query}&page={page_obj.previous_page_number()}"
if base_query
else f"page={page_obj.previous_page_number()}"
)
pagination_urls["previous_url"] = f"?{prev_params}"
if page_obj.has_next():
next_params = (
f"{base_query}&page={page_obj.next_page_number()}"
if base_query
else f"page={page_obj.next_page_number()}"
)
pagination_urls["next_url"] = f"?{next_params}"
# First and last page URLs
if page_obj.number > 1:
first_params = f"{base_query}&page=1" if base_query else "page=1"
pagination_urls["first_url"] = f"?{first_params}"
if page_obj.number < page_obj.paginator.num_pages:
last_params = (
f"{base_query}&page={page_obj.paginator.num_pages}"
if base_query
else f"page={page_obj.paginator.num_pages}"
)
pagination_urls["last_url"] = f"?{last_params}"
return pagination_urls
def search_parks(request: HttpRequest) -> HttpResponse:
"""Search parks and return results using park_list_item.html"""
@@ -499,7 +631,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
Decimal("0.000001"), rounding=ROUND_DOWN
)
def form_valid(self, form: ParkForm) -> HttpResponse:
def form_valid(self, form: ParkForm) -> HttpResponse: # noqa: C901
self.normalize_coordinates(form)
changes = self.prepare_changes_data(form.cleaned_data)

View File

@@ -1,4 +1,4 @@
/* Loading states */
/* Enhanced Loading states */
.htmx-request .htmx-indicator {
opacity: 1;
}
@@ -10,23 +10,140 @@
transition: opacity 200ms ease-in-out;
}
/* Loading pulse animation */
@keyframes loading-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.htmx-request {
animation: loading-pulse 1.5s ease-in-out infinite;
}
/* Results container transitions */
#park-results {
transition: opacity 200ms ease-in-out;
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.htmx-request #park-results {
opacity: 0.7;
transform: scale(0.98);
}
.htmx-settling #park-results {
opacity: 1;
transform: scale(1);
}
/* Filter UI Enhancements */
.quick-filter-btn {
@apply inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 ease-in-out;
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
@apply transform hover:scale-105 active:scale-95;
@apply border border-transparent;
}
.quick-filter-btn:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.filter-count {
@apply text-xs opacity-75 ml-1;
}
/* Filter Chips Styling */
.filter-chip {
@apply inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-800;
@apply dark:bg-blue-800 dark:text-blue-100 transition-all duration-200;
animation: slideIn 0.3s ease-out;
}
.filter-chip:hover {
@apply bg-blue-200 dark:bg-blue-700;
transform: translateY(-1px);
}
.filter-chip .remove-btn {
@apply ml-2 inline-flex items-center justify-center w-4 h-4;
@apply text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100;
@apply focus:outline-none transition-colors duration-150;
}
.filter-chip .remove-btn:hover {
transform: scale(1.1);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Enhanced Search Input */
.search-input {
@apply transition-all duration-200 ease-in-out;
}
.search-input:focus {
@apply ring-2 ring-blue-500 border-blue-500;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Enhanced Form Controls */
.filter-field select,
.filter-field input[type="text"],
.filter-field input[type="number"],
.filter-field input[type="search"],
.form-field-wrapper input,
.form-field-wrapper select {
@apply transition-all duration-200 ease-in-out;
@apply border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white;
@apply focus:border-blue-500 focus:ring-blue-500;
@apply rounded-md shadow-sm;
}
.filter-field select:focus,
.filter-field input:focus,
.form-field-wrapper input:focus,
.form-field-wrapper select:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.filter-field input[type="checkbox"],
.form-field-wrapper input[type="checkbox"] {
@apply rounded transition-colors duration-200;
@apply text-blue-600 focus:ring-blue-500;
@apply border-gray-300 dark:border-gray-600;
}
/* Enhanced Status Indicators */
.status-indicator {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
animation: fadeIn 0.3s ease-out;
}
.status-indicator.filtered {
@apply bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100;
}
.status-indicator.loading {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Grid/List transitions */
.park-card {
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
background-color: white;
border-radius: 0.5rem;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
}
@@ -36,8 +153,8 @@
flex-direction: column;
}
.park-card[data-view-mode="grid"]:hover {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* List view styles */
@@ -48,12 +165,14 @@
}
.park-card[data-view-mode="list"]:hover {
background-color: #f9fafb;
transform: translateX(4px);
}
/* Image containers */
.park-card .image-container {
position: relative;
overflow: hidden;
border-radius: 0.5rem;
}
.park-card[data-view-mode="grid"] .image-container {
aspect-ratio: 16 / 9;
@@ -73,23 +192,26 @@
min-width: 0; /* Enables text truncation in flex child */
}
/* Status badges */
/* Enhanced Status badges */
.park-card .status-badge {
transition: all 150ms ease-in-out;
transition: all 200ms ease-in-out;
border-radius: 9999px;
font-weight: 500;
}
.park-card:hover .status-badge {
transform: scale(1.05);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Images */
.park-card img {
transition: transform 200ms ease-in-out;
transition: transform 300ms ease-in-out;
object-fit: cover;
width: 100%;
height: 100%;
}
.park-card:hover img {
transform: scale(1.05);
transform: scale(1.08);
}
/* Placeholders for missing images */
@@ -97,6 +219,7 @@
background: linear-gradient(110deg, #ececec 8%, #f5f5f5 18%, #ececec 33%);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
border-radius: 0.5rem;
}
@keyframes shimmer {
@@ -105,7 +228,57 @@
}
}
/* Dark mode */
/* Enhanced No Results State */
.no-results {
@apply text-center py-12;
animation: fadeInUp 0.5s ease-out;
}
.no-results-icon {
@apply mx-auto w-24 h-24 text-gray-400 dark:text-gray-500 mb-6;
animation: float 3s ease-in-out infinite;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
/* Enhanced Buttons */
.btn-enhanced {
@apply transition-all duration-200 ease-in-out;
@apply transform hover:scale-105 active:scale-95;
@apply focus:outline-none focus:ring-2 focus:ring-offset-2;
}
.btn-enhanced:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* Tooltip Styles */
.tooltip {
@apply absolute z-50 px-2 py-1 text-xs font-medium text-white bg-gray-900 rounded shadow-lg;
@apply dark:bg-gray-700 dark:text-gray-200;
animation: tooltipFadeIn 0.2s ease-out;
}
@keyframes tooltipFadeIn {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
/* Enhanced Dark mode */
@media (prefers-color-scheme: dark) {
.park-card {
background-color: #1f2937;
@@ -128,7 +301,111 @@
background: linear-gradient(110deg, #2d3748 8%, #374151 18%, #2d3748 33%);
}
.search-input {
@apply dark:bg-gray-700 dark:border-gray-600 dark:text-white;
}
.quick-filter-btn:not(.active) {
@apply dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600;
}
/* Enhanced filter panel styling */
.filter-container {
@apply dark:text-gray-200;
}
.filter-field label {
@apply dark:text-gray-300;
}
}
/* Additional enhancements for better visual hierarchy */
.filter-container h3 {
@apply font-semibold tracking-wide;
}
.filter-field {
@apply mb-4;
}
.filter-field label {
@apply font-medium text-sm;
}
/* Status badge improvements */
.status-badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
@apply transition-all duration-200;
}
/* Loading state improvements */
.htmx-request .filter-container {
@apply pointer-events-none;
}
.htmx-request .quick-filter-btn {
@apply opacity-75;
}
/* Mobile Responsive Enhancements */
@media (max-width: 768px) {
.quick-filter-btn {
@apply text-xs px-2 py-1;
}
.filter-chip {
@apply text-xs px-2 py-1;
}
.park-card[data-view-mode="grid"]:hover {
transform: translateY(-2px);
}
.park-card[data-view-mode="list"]:hover {
background-color: #374151;
transform: none;
}
}
/* Accessibility Enhancements */
@media (prefers-reduced-motion: reduce) {
.park-card,
.quick-filter-btn,
.filter-chip,
.btn-enhanced {
transition: none;
animation: none;
}
.park-card:hover {
transform: none;
}
.no-results-icon {
animation: none;
}
}
/* Focus States for Keyboard Navigation */
.park-card:focus-within {
@apply ring-2 ring-blue-500 ring-offset-2;
}
.quick-filter-btn:focus,
.filter-chip .remove-btn:focus {
@apply ring-2 ring-blue-500 ring-offset-2;
}
/* High Contrast Mode Support */
@media (prefers-contrast: high) {
.park-card {
border-width: 2px;
}
.filter-chip {
border: 2px solid currentColor;
}
.quick-filter-btn {
border: 2px solid currentColor;
}
}

View File

@@ -1,5 +1,50 @@
// Handle view mode persistence across HTMX requests
document.addEventListener('htmx:configRequest', function(evt) {
/**
* Enhanced Parks Search and Filter Management
* Provides comprehensive UX improvements for the parks listing page
*/
class ParkSearchManager {
constructor() {
this.debounceTimers = new Map();
this.filterState = new Map();
this.requestCount = 0;
this.lastRequestTime = 0;
this.init();
}
init() {
this.setupEventListeners();
this.initializeLazyLoading();
this.setupKeyboardNavigation();
this.restoreFilterState();
this.setupPerformanceOptimizations();
}
setupEventListeners() {
// Enhanced HTMX request handling
document.addEventListener('htmx:configRequest', (evt) => this.handleConfigRequest(evt));
document.addEventListener('htmx:beforeRequest', (evt) => this.handleBeforeRequest(evt));
document.addEventListener('htmx:afterRequest', (evt) => this.handleAfterRequest(evt));
document.addEventListener('htmx:responseError', (evt) => this.handleResponseError(evt));
document.addEventListener('htmx:historyRestore', (evt) => this.handleHistoryRestore(evt));
document.addEventListener('htmx:afterSwap', (evt) => this.handleAfterSwap(evt));
// Enhanced form interactions
document.addEventListener('input', (evt) => this.handleInput(evt));
document.addEventListener('change', (evt) => this.handleChange(evt));
document.addEventListener('focus', (evt) => this.handleFocus(evt));
document.addEventListener('blur', (evt) => this.handleBlur(evt));
// Search suggestions
document.addEventListener('keydown', (evt) => this.handleKeydown(evt));
// Window events
window.addEventListener('beforeunload', () => this.saveFilterState());
window.addEventListener('resize', this.debounce(() => this.handleResize(), 250));
}
handleConfigRequest(evt) {
// Preserve view mode
const parkResults = document.getElementById('park-results');
if (parkResults) {
@@ -10,29 +55,62 @@ document.addEventListener('htmx:configRequest', function(evt) {
}
// Preserve search terms
const searchInput = document.getElementById('search');
const searchInput = document.querySelector('input[name="search"]');
if (searchInput && searchInput.value) {
evt.detail.parameters['search'] = searchInput.value;
}
});
// Handle loading states
document.addEventListener('htmx:beforeRequest', function(evt) {
// Add request tracking
evt.detail.parameters['_req_id'] = ++this.requestCount;
this.lastRequestTime = Date.now();
}
handleBeforeRequest(evt) {
const target = evt.detail.target;
if (target) {
target.classList.add('htmx-requesting');
this.showLoadingIndicator(target);
}
});
document.addEventListener('htmx:afterRequest', function(evt) {
// Disable form elements during request
this.toggleFormElements(false);
// Track request analytics
this.trackFilterUsage(evt);
}
handleAfterRequest(evt) {
const target = evt.detail.target;
if (target) {
target.classList.remove('htmx-requesting');
this.hideLoadingIndicator(target);
}
});
// Handle history navigation
document.addEventListener('htmx:historyRestore', function(evt) {
// Re-enable form elements
this.toggleFormElements(true);
// Handle response timing
const responseTime = Date.now() - this.lastRequestTime;
if (responseTime > 3000) {
this.showPerformanceWarning();
}
}
handleResponseError(evt) {
this.hideLoadingIndicator(evt.detail.target);
this.toggleFormElements(true);
this.showErrorMessage('Failed to load results. Please try again.');
}
handleAfterSwap(evt) {
if (evt.detail.target.id === 'results-container') {
this.initializeLazyLoading(evt.detail.target);
this.updateResultsInfo(evt.detail.target);
this.animateResults(evt.detail.target);
}
}
handleHistoryRestore(evt) {
const parkResults = document.getElementById('park-results');
if (parkResults && evt.detail.path) {
const url = new URL(evt.detail.path, window.location.origin);
@@ -41,21 +119,140 @@ document.addEventListener('htmx:historyRestore', function(evt) {
parkResults.setAttribute('data-view-mode', viewMode);
}
}
});
// Initialize lazy loading for images
function initializeLazyLoading(container) {
// Restore filter state from URL
this.restoreFiltersFromURL(evt.detail.path);
}
handleInput(evt) {
if (evt.target.type === 'search' || evt.target.type === 'text') {
this.debounceInput(evt.target);
}
}
handleChange(evt) {
if (evt.target.closest('#filter-form')) {
this.updateFilterState();
this.saveFilterState();
}
}
handleFocus(evt) {
if (evt.target.type === 'search') {
this.highlightSearchSuggestions(evt.target);
}
}
handleBlur(evt) {
if (evt.target.type === 'search') {
// Delay hiding suggestions to allow for clicks
setTimeout(() => this.hideSearchSuggestions(), 150);
}
}
handleKeydown(evt) {
if (evt.target.type === 'search') {
this.handleSearchKeyboard(evt);
}
}
handleResize() {
// Responsive adjustments
this.adjustLayoutForViewport();
}
debounceInput(input) {
const key = input.name || input.id;
if (this.debounceTimers.has(key)) {
clearTimeout(this.debounceTimers.get(key));
}
const delay = input.type === 'search' ? 300 : 500;
const timer = setTimeout(() => {
if (input.form) {
htmx.trigger(input.form, 'change');
}
this.debounceTimers.delete(key);
}, delay);
this.debounceTimers.set(key, timer);
}
handleSearchKeyboard(evt) {
const suggestions = document.getElementById('search-results');
if (!suggestions) return;
const items = suggestions.querySelectorAll('[role="option"]');
let activeIndex = Array.from(items).findIndex(item =>
item.classList.contains('active') || item.classList.contains('highlighted')
);
switch (evt.key) {
case 'ArrowDown':
evt.preventDefault();
activeIndex = Math.min(activeIndex + 1, items.length - 1);
this.highlightSuggestion(items, activeIndex);
break;
case 'ArrowUp':
evt.preventDefault();
activeIndex = Math.max(activeIndex - 1, -1);
this.highlightSuggestion(items, activeIndex);
break;
case 'Enter':
if (activeIndex >= 0 && items[activeIndex]) {
evt.preventDefault();
items[activeIndex].click();
}
break;
case 'Escape':
this.hideSearchSuggestions();
evt.target.blur();
break;
}
}
highlightSuggestion(items, activeIndex) {
items.forEach((item, index) => {
item.classList.toggle('active', index === activeIndex);
item.classList.toggle('highlighted', index === activeIndex);
});
}
highlightSearchSuggestions(input) {
const suggestions = document.getElementById('search-results');
if (suggestions && input.value) {
suggestions.style.display = 'block';
}
}
hideSearchSuggestions() {
const suggestions = document.getElementById('search-results');
if (suggestions) {
suggestions.style.display = 'none';
}
}
initializeLazyLoading(container = document) {
if (!('IntersectionObserver' in window)) return;
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
img.classList.add('loaded');
imageObserver.unobserve(img);
}
}
});
}, {
threshold: 0.1,
rootMargin: '50px'
});
container.querySelectorAll('img[data-src]').forEach(img => {
@@ -63,7 +260,291 @@ function initializeLazyLoading(container) {
});
}
// Initialize lazy loading after HTMX content swaps
document.addEventListener('htmx:afterSwap', function(evt) {
initializeLazyLoading(evt.detail.target);
setupKeyboardNavigation() {
// Tab navigation for filter cards
document.addEventListener('keydown', (evt) => {
if (evt.key === 'Tab' && evt.target.closest('.park-card')) {
this.handleCardNavigation(evt);
}
});
}
setupPerformanceOptimizations() {
// Prefetch next page if pagination exists
this.setupPrefetching();
// Optimize scroll performance
this.setupScrollOptimization();
}
setupPrefetching() {
const nextPageLink = document.querySelector('a[rel="next"]');
if (nextPageLink && 'IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.prefetchPage(nextPageLink.href);
observer.unobserve(entry.target);
}
});
});
const trigger = document.querySelector('.pagination');
if (trigger) {
observer.observe(trigger);
}
}
}
setupScrollOptimization() {
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
this.handleScroll();
ticking = false;
});
ticking = true;
}
});
}
handleScroll() {
// Show/hide back to top button
const backToTop = document.getElementById('back-to-top');
if (backToTop) {
backToTop.style.display = window.scrollY > 500 ? 'block' : 'none';
}
}
prefetchPage(url) {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
document.head.appendChild(link);
}
showLoadingIndicator(target) {
// Add subtle loading animation
target.style.transition = 'opacity 0.3s ease-in-out';
target.style.opacity = '0.7';
}
hideLoadingIndicator(target) {
target.style.opacity = '1';
}
toggleFormElements(enabled) {
const form = document.getElementById('filter-form');
if (form) {
const elements = form.querySelectorAll('input, select, button');
elements.forEach(el => {
el.disabled = !enabled;
});
}
}
updateFilterState() {
const form = document.getElementById('filter-form');
if (!form) return;
const formData = new FormData(form);
this.filterState.clear();
for (const [key, value] of formData.entries()) {
if (value && value !== '') {
this.filterState.set(key, value);
}
}
}
saveFilterState() {
try {
const state = Object.fromEntries(this.filterState);
localStorage.setItem('parkFilters', JSON.stringify(state));
} catch (e) {
console.warn('Failed to save filter state:', e);
}
}
restoreFilterState() {
try {
const saved = localStorage.getItem('parkFilters');
if (saved) {
const state = JSON.parse(saved);
this.applyFilterState(state);
}
} catch (e) {
console.warn('Failed to restore filter state:', e);
}
}
restoreFiltersFromURL(path) {
const url = new URL(path, window.location.origin);
const params = new URLSearchParams(url.search);
const form = document.getElementById('filter-form');
if (!form) return;
// Clear existing values
form.reset();
// Apply URL parameters
for (const [key, value] of params.entries()) {
const input = form.querySelector(`[name="${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value === 'on' || value === 'true';
} else {
input.value = value;
}
}
}
}
applyFilterState(state) {
const form = document.getElementById('filter-form');
if (!form) return;
Object.entries(state).forEach(([key, value]) => {
const input = form.querySelector(`[name="${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value === 'on' || value === 'true';
} else {
input.value = value;
}
}
});
}
updateResultsInfo(container) {
// Update any result count displays
const countElements = container.querySelectorAll('[data-result-count]');
countElements.forEach(el => {
const count = container.querySelectorAll('.park-card').length;
el.textContent = count;
});
}
animateResults(container) {
// Subtle animation for new results
const cards = container.querySelectorAll('.park-card');
cards.forEach((card, index) => {
card.style.opacity = '0';
card.style.transform = 'translateY(20px)';
setTimeout(() => {
card.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out';
card.style.opacity = '1';
card.style.transform = 'translateY(0)';
}, index * 50);
});
}
adjustLayoutForViewport() {
const viewport = window.innerWidth;
// Adjust grid columns based on viewport
const grid = document.querySelector('.park-card-grid');
if (grid) {
if (viewport < 768) {
grid.style.gridTemplateColumns = '1fr';
} else if (viewport < 1024) {
grid.style.gridTemplateColumns = 'repeat(2, 1fr)';
} else {
grid.style.gridTemplateColumns = 'repeat(3, 1fr)';
}
}
}
trackFilterUsage(evt) {
// Track which filters are being used for analytics
if (window.gtag) {
const formData = new FormData(evt.detail.elt);
const activeFilters = [];
for (const [key, value] of formData.entries()) {
if (value && value !== '' && key !== 'csrfmiddlewaretoken') {
activeFilters.push(key);
}
}
window.gtag('event', 'filter_usage', {
'filters_used': activeFilters.join(','),
'filter_count': activeFilters.length
});
}
}
showPerformanceWarning() {
// Show a subtle warning for slow responses
const warning = document.createElement('div');
warning.className = 'fixed top-4 right-4 bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded z-50';
warning.innerHTML = `
<span class="block sm:inline">Search is taking longer than expected...</span>
<span class="absolute top-0 bottom-0 right-0 px-4 py-3 cursor-pointer" onclick="this.parentElement.remove()">
<svg class="fill-current h-6 w-6 text-yellow-500" role="button" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651 3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1.2 1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1 1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1.698z"/>
</svg>
</span>
`;
document.body.appendChild(warning);
setTimeout(() => {
if (warning.parentElement) {
warning.remove();
}
}, 5000);
}
showErrorMessage(message) {
// Show error message with retry option
const errorDiv = document.createElement('div');
errorDiv.className = 'fixed top-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50';
errorDiv.innerHTML = `
<span class="block sm:inline">${message}</span>
<button class="ml-4 bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded text-sm" onclick="location.reload()">
Retry
</button>
<span class="absolute top-0 bottom-0 right-0 px-4 py-3 cursor-pointer" onclick="this.parentElement.remove()">
<svg class="fill-current h-6 w-6 text-red-500" role="button" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651 3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1.2 1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1 1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1.698z"/>
</svg>
</span>
`;
document.body.appendChild(errorDiv);
setTimeout(() => {
if (errorDiv.parentElement) {
errorDiv.remove();
}
}, 10000);
}
// Utility method for debouncing
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
}
// Initialize the enhanced search manager
document.addEventListener('DOMContentLoaded', () => {
window.parkSearchManager = new ParkSearchManager();
});
// Export for potential module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = ParkSearchManager;
}

View File

@@ -1,20 +1,122 @@
{# Enhanced filter form with modern UI/UX design - timestamp: 2025-08-21 #}
{% load static %}
{% load filter_utils %}
<div class="filter-container" x-data="{ open: false }">
<div class="filter-container" x-data="filterManager()" x-init="init()">
{# Quick Filter Presets - Enhanced Design #}
<div class="mb-8 bg-gradient-to-br from-white via-gray-50 to-white dark:from-gray-800 dark:via-gray-800 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 p-6 backdrop-blur-sm">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center space-x-3">
<div class="w-2 h-8 bg-gradient-to-b from-blue-500 to-purple-600 rounded-full"></div>
<div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Quick Filters</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Find parks instantly with preset filters</p>
</div>
</div>
<div class="flex items-center space-x-2">
<div class="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
<span class="text-xs text-gray-500 dark:text-gray-400">Live filtering</span>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<button type="button"
@click="applyQuickFilter('disney')"
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
:class="isQuickFilterActive('disney') ? 'bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-lg ring-2 ring-blue-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
title="Show Disney Parks">
<div class="flex flex-col items-center text-center space-y-2">
<div class="relative">
<svg class="w-8 h-8 transition-transform duration-300 group-hover:rotate-12" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
<div class="absolute -top-1 -right-1 w-3 h-3 bg-yellow-400 rounded-full animate-ping" x-show="isQuickFilterActive('disney')"></div>
</div>
<div>
<div class="font-semibold text-sm">Disney Parks</div>
<div class="text-xs opacity-75" x-show="quickFilterCounts.disney" x-text="`${quickFilterCounts.disney} parks`"></div>
</div>
</div>
<div class="absolute inset-0 bg-gradient-to-br from-blue-400/10 to-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
</button>
<button type="button"
@click="applyQuickFilter('coasters')"
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
:class="isQuickFilterActive('coasters') ? 'bg-gradient-to-br from-green-500 to-green-600 text-white shadow-lg ring-2 ring-green-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-green-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
title="Parks with Roller Coasters">
<div class="flex flex-col items-center text-center space-y-2">
<div class="relative">
<svg class="w-8 h-8 transition-transform duration-300 group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.94-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
<div class="absolute -top-1 -right-1 w-3 h-3 bg-red-400 rounded-full animate-ping" x-show="isQuickFilterActive('coasters')"></div>
</div>
<div>
<div class="font-semibold text-sm">With Coasters</div>
<div class="text-xs opacity-75" x-show="quickFilterCounts.coasters" x-text="`${quickFilterCounts.coasters} parks`"></div>
</div>
</div>
<div class="absolute inset-0 bg-gradient-to-br from-green-400/10 to-emerald-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
</button>
<button type="button"
@click="applyQuickFilter('top_rated')"
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2"
:class="isQuickFilterActive('top_rated') ? 'bg-gradient-to-br from-yellow-500 to-yellow-600 text-white shadow-lg ring-2 ring-yellow-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-yellow-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
title="Highly Rated Parks (4+ stars)">
<div class="flex flex-col items-center text-center space-y-2">
<div class="relative">
<svg class="w-8 h-8 transition-transform duration-300 group-hover:rotate-12" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
<div class="absolute -top-1 -right-1 w-3 h-3 bg-yellow-400 rounded-full animate-ping" x-show="isQuickFilterActive('top_rated')"></div>
</div>
<div>
<div class="font-semibold text-sm">Top Rated</div>
<div class="text-xs opacity-75" x-show="quickFilterCounts.top_rated" x-text="`${quickFilterCounts.top_rated} parks`"></div>
</div>
</div>
<div class="absolute inset-0 bg-gradient-to-br from-yellow-400/10 to-orange-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
</button>
<button type="button"
@click="applyQuickFilter('major_parks')"
class="group relative overflow-hidden rounded-xl p-4 transition-all duration-300 ease-out transform hover:scale-105 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
:class="isQuickFilterActive('major_parks') ? 'bg-gradient-to-br from-purple-500 to-purple-600 text-white shadow-lg ring-2 ring-purple-500/20' : 'bg-white dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-purple-50 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'"
title="Major Theme Parks (10+ rides)">
<div class="flex flex-col items-center text-center space-y-2">
<div class="relative">
<svg class="w-8 h-8 transition-transform duration-300 group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<div class="absolute -top-1 -right-1 w-3 h-3 bg-purple-400 rounded-full animate-ping" x-show="isQuickFilterActive('major_parks')"></div>
</div>
<div>
<div class="font-semibold text-sm">Major Parks</div>
<div class="text-xs opacity-75" x-show="quickFilterCounts.major_parks" x-text="`${quickFilterCounts.major_parks} parks`"></div>
</div>
</div>
<div class="absolute inset-0 bg-gradient-to-br from-purple-400/10 to-pink-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
</button>
</div>
</div>
{# Mobile Filter Toggle #}
<div class="lg:hidden bg-white rounded-lg shadow p-4 mb-4">
<button @click="open = !open" type="button" class="w-full flex items-center justify-between p-2">
<span class="font-medium text-gray-900">
<div class="lg:hidden bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-6">
<button @click="toggleMobileFilters()" type="button" class="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-md transition-colors duration-200">
<span class="font-medium text-gray-900 dark:text-white">
<span class="mr-2">
<svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path>
</svg>
</span>
Filter Options
<span x-show="activeFilterCount > 0"
class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100"
x-text="activeFilterCount"></span>
</span>
<span class="text-gray-500">
<svg class="w-5 h-5 transition-transform duration-200" :class="{'rotate-180': open}" fill="currentColor" viewBox="0 0 20 20">
<span class="text-gray-500 dark:text-gray-400">
<svg class="w-5 h-5 transition-transform duration-200" :class="{'rotate-180': mobileFiltersOpen}" fill="currentColor" viewBox="0 0 20 20">
<path 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"></path>
</svg>
</span>
@@ -23,89 +125,340 @@
{# Filter Form #}
<form hx-get="{{ request.path }}"
hx-trigger="change delay:500ms"
hx-trigger="change delay:300ms, submit"
hx-target="#results-container"
hx-push-url="true"
hx-indicator="#filter-loading"
class="space-y-6"
x-show="open || $screen('lg')"
x-transition>
x-show="mobileFiltersOpen || $screen('lg')"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@change="updateActiveFilterCount()">
{# Loading Indicator #}
<div id="filter-loading" class="htmx-indicator fixed top-4 right-4 z-50">
<div class="bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" 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>
Updating results...
</div>
</div>
{# Active Filters Summary #}
{% if applied_filters %}
<div class="bg-blue-50 rounded-lg p-4 shadow-xs border border-blue-100">
<div x-show="activeFilterCount > 0"
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"
class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 shadow-xs border border-blue-100 dark:border-blue-800">
<div class="flex justify-between items-center">
<div>
<h3 class="text-sm font-medium text-blue-800">Active Filters</h3>
<p class="text-xs text-blue-600 mt-1">{{ applied_filters|length }} filter{{ applied_filters|length|pluralize }} applied</p>
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Active Filters</h3>
<p class="text-xs text-blue-600 dark:text-blue-300 mt-1" x-text="`${activeFilterCount} filter${activeFilterCount !== 1 ? 's' : ''} applied`"></p>
</div>
<a href="{{ request.path }}"
class="text-sm font-medium text-blue-600 hover:text-blue-500 hover:underline"
hx-get="{{ request.path }}"
hx-target="#results-container"
hx-push-url="true">
<button type="button"
@click="clearAllFilters()"
class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 hover:underline focus:outline-none focus:underline transition-colors duration-150"
aria-label="Clear all filters">
Clear All
</a>
</button>
</div>
</div>
{% endif %}
{# Filter Groups #}
<div class="bg-white rounded-lg shadow divide-y divide-gray-200">
{% for fieldset in filter.form|groupby_filters %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
<div class="p-6" x-data="{ expanded: true }">
{# Group Header #}
<button type="button"
@click="expanded = !expanded"
class="w-full flex justify-between items-center text-left">
<h3 class="text-lg font-medium text-gray-900">{{ fieldset.name }}</h3>
<svg class="w-5 h-5 text-gray-500 transform transition-transform duration-200"
class="w-full flex justify-between items-center text-left focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-md p-1 -m-1"
:aria-expanded="expanded"
aria-controls="filter-group-1">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Search & Filter Options</h3>
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400 transform transition-transform duration-200"
:class="{'rotate-180': !expanded}"
fill="currentColor"
viewBox="0 0 20 20">
viewBox="0 0 20 20"
aria-hidden="true">
<path 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"/>
</svg>
</button>
{# Group Content #}
<div class="mt-4 space-y-4" x-show="expanded" x-collapse>
{% for field in fieldset.fields %}
<div id="filter-group-1"
class="mt-4 space-y-4"
x-show="expanded"
x-collapse>
{% for field in filter.form %}
<div class="filter-field">
<label for="{{ field.id_for_label }}"
class="block text-sm font-medium text-gray-700 mb-1">
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1 flex items-center">
{{ field.label }}
{% if field.help_text %}
<button type="button"
class="ml-2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 focus:outline-none"
@click="$tooltip('{{ field.help_text|escapejs }}', $event)"
aria-label="Help for {{ field.label }}">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
</svg>
</button>
{% endif %}
</label>
<div class="mt-2 relative">
<div class="form-field-wrapper">
{% if field.field.widget.input_type == 'text' or field.field.widget.input_type == 'search' %}
<input type="{{ field.field.widget.input_type }}"
name="{{ field.name }}"
id="{{ field.id_for_label }}"
value="{{ field.value|default_if_none:'' }}"
placeholder="{{ field.field.widget.attrs.placeholder|default:'' }}"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm transition-colors duration-200">
{% elif field.field.widget.input_type == 'number' %}
<input type="number"
name="{{ field.name }}"
id="{{ field.id_for_label }}"
value="{{ field.value|default_if_none:'' }}"
min="{{ field.field.widget.attrs.min|default:'' }}"
max="{{ field.field.widget.attrs.max|default:'' }}"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm transition-colors duration-200">
{% elif field.field.widget.input_type == 'checkbox' %}
<div class="flex items-center">
<input type="checkbox"
name="{{ field.name }}"
id="{{ field.id_for_label }}"
{% if field.value %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded transition-colors duration-200">
<label for="{{ field.id_for_label }}" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ field.label }}
</label>
<div class="mt-1">
{{ field|add_field_classes }}
</div>
{% if field.help_text %}
<p class="mt-1 text-sm text-gray-500">{{ field.help_text }}</p>
{% elif field.field.widget.input_type == 'select' %}
<select name="{{ field.name }}"
id="{{ field.id_for_label }}"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm transition-colors duration-200">
{% for choice in field.field.choices %}
<option value="{{ choice.0 }}" {% if choice.0 == field.value %}selected{% endif %}>{{ choice.1 }}</option>
{% endfor %}
</select>
{% else %}
{{ field }}
{% endif %}
</div>
{% endfor %}
{% if field.field.widget.input_type == 'select' %}
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700 dark:text-gray-300">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"/>
</svg>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{# Mobile Apply Button #}
<div class="lg:hidden">
<button type="submit"
class="w-full bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition duration-150 ease-in-out">
Apply Filters
class="w-full bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLoading">
<span x-show="!isLoading">Apply Filters</span>
<span x-show="isLoading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" 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>
Applying...
</span>
</button>
</div>
</form>
</div>
{# Required Scripts #}
{# Enhanced Scripts #}
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('filterForm', () => ({
expanded: true,
toggle() {
this.expanded = !this.expanded
Alpine.data('filterManager', () => ({
mobileFiltersOpen: false,
activeFilterCount: 0,
isLoading: false,
quickFilterCounts: {
disney: 0,
coasters: 0,
top_rated: 0,
major_parks: 0
},
init() {
this.updateActiveFilterCount();
this.loadQuickFilterCounts();
// Listen for HTMX events
document.addEventListener('htmx:beforeRequest', () => {
this.isLoading = true;
});
document.addEventListener('htmx:afterRequest', () => {
this.isLoading = false;
});
document.addEventListener('htmx:responseError', () => {
this.isLoading = false;
});
},
toggleMobileFilters() {
this.mobileFiltersOpen = !this.mobileFiltersOpen;
},
updateActiveFilterCount() {
// Count active filters from form inputs
const form = this.$el.querySelector('form');
if (!form) return;
let count = 0;
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
if (input.name && input.value && input.value !== '' && input.value !== 'all') {
// Skip hidden fields and empty values
if (input.type !== 'hidden' && input.value !== '0') {
count++;
}
}))
})
}
});
this.activeFilterCount = count;
},
clearAllFilters() {
const form = this.$el.querySelector('form');
if (!form) return;
// Clear all form inputs
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
if (input.type === 'checkbox' || input.type === 'radio') {
input.checked = false;
} else if (input.type !== 'hidden') {
input.value = '';
}
});
// Trigger form submission
this.activeFilterCount = 0;
htmx.trigger(form, 'submit');
},
applyQuickFilter(filterType) {
const form = this.$el.querySelector('form');
if (!form) return;
// Clear existing filters first
this.clearAllFilters();
// Apply specific filter based on type
switch(filterType) {
case 'disney':
const parkTypeField = form.querySelector('[name="park_type"]');
if (parkTypeField) parkTypeField.value = 'disney';
break;
case 'coasters':
const coastersField = form.querySelector('[name="has_coasters"]');
if (coastersField) coastersField.checked = true;
break;
case 'top_rated':
const ratingField = form.querySelector('[name="min_rating"]');
if (ratingField) ratingField.value = '4';
break;
case 'major_parks':
const bigParksField = form.querySelector('[name="big_parks_only"]');
if (bigParksField) bigParksField.checked = true;
break;
}
this.updateActiveFilterCount();
htmx.trigger(form, 'submit');
},
isQuickFilterActive(filterType) {
const form = this.$el.querySelector('form');
if (!form) return false;
switch(filterType) {
case 'disney':
const parkTypeField = form.querySelector('[name="park_type"]');
return parkTypeField && parkTypeField.value === 'disney';
case 'coasters':
const coastersField = form.querySelector('[name="has_coasters"]');
return coastersField && coastersField.checked;
case 'top_rated':
const ratingField = form.querySelector('[name="min_rating"]');
return ratingField && ratingField.value === '4';
case 'major_parks':
const bigParksField = form.querySelector('[name="big_parks_only"]');
return bigParksField && bigParksField.checked;
}
return false;
},
loadQuickFilterCounts() {
// This would typically fetch from an API endpoint
// For now, set some placeholder values
this.quickFilterCounts = {
disney: 12,
coasters: 156,
top_rated: 89,
major_parks: 78
};
}
}));
// Tooltip directive
Alpine.directive('tooltip', (el, { expression }) => {
el._tooltip = expression;
});
// Global tooltip function
window.$tooltip = function(text, event) {
// Simple tooltip implementation - could be enhanced with a library
const tooltip = document.createElement('div');
tooltip.className = 'fixed z-50 bg-gray-900 text-white text-sm rounded py-1 px-2 pointer-events-none';
tooltip.textContent = text;
tooltip.style.left = event.pageX + 10 + 'px';
tooltip.style.top = event.pageY - 30 + 'px';
document.body.appendChild(tooltip);
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
}, 3000);
};
});
// Enhanced debouncing for text inputs
document.addEventListener('input', function(e) {
if (e.target.type === 'text' || e.target.type === 'search') {
clearTimeout(e.target._debounceTimer);
e.target._debounceTimer = setTimeout(() => {
htmx.trigger(e.target.form, 'change');
}, 300);
}
});
</script>

View File

@@ -1,26 +1,29 @@
{% extends "base.html" %}
{% extends "base/base.html" %}
{% load static %}
{% block title %}{{ view.model|model_name_plural|title }} - ThrillWiki{% endblock %}
{% block title %}Parks - ThrillWiki{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="flex flex-col lg:flex-row gap-8">
<div class="flex flex-col lg:flex-row gap-6">
{# Filters Sidebar #}
<div class="lg:w-1/4">
{% include "search/components/filter_form.html" %}
<div class="lg:w-1/4 lg:sticky lg:top-4 lg:self-start">
{% block filter_section %}
<!-- DEBUG: base filtered_list.html filter_section block - timestamp: 2025-08-21 -->
{% include "core/search/components/filter_form.html" %}
{% endblock %}
</div>
{# Results Section #}
<div class="lg:w-3/4">
<div id="results-container">
{# Result count and sorting #}
<div class="bg-white rounded-lg shadow mb-4">
<div class="p-4 border-b">
<div class="flex justify-between items-center">
<h2 class="text-xl font-bold">
{{ view.model|model_name_plural|title }}
<span class="text-sm font-normal text-gray-500">({{ page_obj.paginator.count }} found)</span>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 mb-6">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">
Parks
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ page_obj.paginator.count }} found)</span>
</h2>
{% block list_actions %}
@@ -32,14 +35,14 @@
{# Results list #}
{% block results_list %}
<div class="bg-white rounded-lg shadow">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
{% include results_template|default:"search/partials/generic_results.html" %}
</div>
{% endblock %}
{# Pagination #}
{% if is_paginated %}
<div class="mt-4">
<div class="mt-6">
{% include "search/components/pagination.html" %}
</div>
{% endif %}

View File

@@ -1,225 +0,0 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}Parks - ThrillWiki{% endblock %}
{% block content %}
<div class="container px-4 mx-auto">
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">All Parks</h1>
<div hx-get="{% url 'parks:add_park_button' %}" hx-trigger="load"></div>
</div>
<!-- Filters -->
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<form id="park-filters" class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"
hx-get="{% url 'parks:park_list' %}"
hx-trigger="change from:select, input from:input[type='text'] delay:500ms, click from:.status-filter"
hx-target="#parks-grid"
hx-push-url="true">
<div>
<label for="search" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
<input type="text" name="search" id="search"
value="{{ current_filters.search }}"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search parks...">
</div>
<!-- Country Field -->
<div x-data="locationSearch('country')" class="relative">
<label for="country" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Country</label>
<input type="text"
name="country"
id="country"
x-model="query"
@input.debounce.300ms="search()"
@focus="search()"
@click.away="results = []"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Select country..."
value="{{ current_filters.country }}"
autocomplete="off">
<!-- Results Dropdown -->
<ul x-show="results.length > 0"
x-cloak
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60">
<template x-for="result in results" :key="result.address.country">
<li @click="select(result)"
x-text="result.address.country"
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
</li>
</template>
</ul>
</div>
<!-- Region Field -->
<div x-data="locationSearch('state')" class="relative">
<label for="region" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">State/Region</label>
<input type="text"
name="region"
id="region"
x-model="query"
@input.debounce.300ms="search()"
@focus="search()"
@click.away="results = []"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Select state/region..."
value="{{ current_filters.region }}"
autocomplete="off">
<!-- Results Dropdown -->
<ul x-show="results.length > 0"
x-cloak
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60">
<template x-for="result in results" :key="result.address.state">
<li @click="select(result)"
x-text="result.address.state"
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
</li>
</template>
</ul>
</div>
<!-- City Field -->
<div x-data="locationSearch('city')" class="relative">
<label for="city" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">City</label>
<input type="text"
name="city"
id="city"
x-model="query"
@input.debounce.300ms="search()"
@focus="search()"
@click.away="results = []"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Select city..."
value="{{ current_filters.city }}"
autocomplete="off">
<!-- Results Dropdown -->
<ul x-show="results.length > 0"
x-cloak
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60">
<template x-for="result in results" :key="result.address.city">
<li @click="select(result)"
x-text="result.address.city"
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
</li>
</template>
</ul>
</div>
<!-- Hidden inputs for selected statuses -->
{% for status in current_filters.statuses %}
<input type="hidden" name="status" value="{{ status }}">
{% endfor %}
</form>
<!-- Status Filter Icons -->
<div class="flex flex-wrap gap-2 mt-4">
<label class="block w-full mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Status Filter</label>
<button type="button"
class="status-filter status-badge status-operating {% if 'OPERATING' in current_filters.statuses %}ring-2 ring-blue-500{% endif %}"
data-status="OPERATING"
onclick="toggleStatus(this, 'OPERATING')">
<i class="mr-1 fas fa-check-circle"></i>Operating
</button>
<button type="button"
class="status-filter status-badge status-closed {% if 'CLOSED_TEMP' in current_filters.statuses %}ring-2 ring-blue-500{% endif %}"
data-status="CLOSED_TEMP"
onclick="toggleStatus(this, 'CLOSED_TEMP')">
<i class="mr-1 fas fa-clock"></i>Temporarily Closed
</button>
<button type="button"
class="status-filter status-badge status-closed {% if 'CLOSED_PERM' in current_filters.statuses %}ring-2 ring-blue-500{% endif %}"
data-status="CLOSED_PERM"
onclick="toggleStatus(this, 'CLOSED_PERM')">
<i class="mr-1 fas fa-times-circle"></i>Permanently Closed
</button>
<button type="button"
class="status-filter status-badge status-construction {% if 'UNDER_CONSTRUCTION' in current_filters.statuses %}ring-2 ring-blue-500{% endif %}"
data-status="UNDER_CONSTRUCTION"
onclick="toggleStatus(this, 'UNDER_CONSTRUCTION')">
<i class="mr-1 fas fa-hard-hat"></i>Under Construction
</button>
<button type="button"
class="status-filter status-badge status-demolished {% if 'DEMOLISHED' in current_filters.statuses %}ring-2 ring-blue-500{% endif %}"
data-status="DEMOLISHED"
onclick="toggleStatus(this, 'DEMOLISHED')">
<i class="mr-1 fas fa-ban"></i>Demolished
</button>
<button type="button"
class="status-filter status-badge status-relocated {% if 'RELOCATED' in current_filters.statuses %}ring-2 ring-blue-500{% endif %}"
data-status="RELOCATED"
onclick="toggleStatus(this, 'RELOCATED')">
<i class="mr-1 fas fa-truck-moving"></i>Relocated
</button>
</div>
</div>
<!-- Parks Grid -->
<div id="parks-grid">
{% include "parks/partials/park_list.html" %}
</div>
</div>
<script>
function locationSearch(type) {
return {
query: '',
results: [],
async search() {
if (!this.query.trim()) {
this.results = [];
return;
}
try {
const response = await fetch(`/location/search/?q=${encodeURIComponent(this.query)}&type=${type}&filter_parks=true`);
const data = await response.json();
this.results = data.results;
} catch (error) {
console.error('Search failed:', error);
this.results = [];
}
},
select(result) {
let value = '';
switch (type) {
case 'country':
value = result.address.country;
break;
case 'state':
value = result.address.state;
break;
case 'city':
value = result.address.city;
break;
}
this.query = value;
this.results = [];
document.getElementById('park-filters').dispatchEvent(new Event('change'));
}
};
}
function toggleStatus(button, status) {
const form = document.getElementById('park-filters');
const existingInputs = form.querySelectorAll(`input[name="status"][value="${status}"]`);
if (existingInputs.length > 0) {
// Status is already selected, remove it
existingInputs.forEach(input => input.remove());
button.classList.remove('ring-2', 'ring-blue-500');
} else {
// Status is not selected, add it
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'status';
input.value = status;
form.appendChild(input);
button.classList.add('ring-2', 'ring-blue-500');
}
// Trigger form submission
form.dispatchEvent(new Event('change'));
}
</script>
{% endblock %}

View File

@@ -0,0 +1 @@
../../../core/search/components/filter_form.html