mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:11:08 -05:00
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:
151
parks/filters.py
151
parks/filters.py
@@ -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.
|
||||
|
||||
62
parks/migrations/0001_add_filter_indexes.py
Normal file
62
parks/migrations/0001_add_filter_indexes.py
Normal 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;",
|
||||
),
|
||||
]
|
||||
13
parks/migrations/0005_merge_20250820_2020.py
Normal file
13
parks/migrations/0005_merge_20250820_2020.py
Normal 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 = []
|
||||
@@ -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"]
|
||||
|
||||
304
parks/services/filter_service.py
Normal file
304
parks/services/filter_service.py
Normal 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
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
.park-card[data-view-mode="list"]:hover {
|
||||
background-color: #374151;
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,550 @@
|
||||
// Handle view mode persistence across HTMX requests
|
||||
document.addEventListener('htmx:configRequest', function(evt) {
|
||||
// Preserve view mode
|
||||
const parkResults = document.getElementById('park-results');
|
||||
if (parkResults) {
|
||||
const viewMode = parkResults.getAttribute('data-view-mode');
|
||||
if (viewMode) {
|
||||
evt.detail.parameters['view_mode'] = viewMode;
|
||||
/**
|
||||
* 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) {
|
||||
const viewMode = parkResults.getAttribute('data-view-mode');
|
||||
if (viewMode) {
|
||||
evt.detail.parameters['view_mode'] = viewMode;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve search terms
|
||||
const searchInput = document.querySelector('input[name="search"]');
|
||||
if (searchInput && searchInput.value) {
|
||||
evt.detail.parameters['search'] = searchInput.value;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Re-enable form elements
|
||||
this.toggleFormElements(true);
|
||||
|
||||
// Handle response timing
|
||||
const responseTime = Date.now() - this.lastRequestTime;
|
||||
if (responseTime > 3000) {
|
||||
this.showPerformanceWarning();
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve search terms
|
||||
const searchInput = document.getElementById('search');
|
||||
if (searchInput && searchInput.value) {
|
||||
evt.detail.parameters['search'] = searchInput.value;
|
||||
handleResponseError(evt) {
|
||||
this.hideLoadingIndicator(evt.detail.target);
|
||||
this.toggleFormElements(true);
|
||||
this.showErrorMessage('Failed to load results. Please try again.');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle loading states
|
||||
document.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
const target = evt.detail.target;
|
||||
if (target) {
|
||||
target.classList.add('htmx-requesting');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(evt) {
|
||||
const target = evt.detail.target;
|
||||
if (target) {
|
||||
target.classList.remove('htmx-requesting');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle history navigation
|
||||
document.addEventListener('htmx:historyRestore', function(evt) {
|
||||
const parkResults = document.getElementById('park-results');
|
||||
if (parkResults && evt.detail.path) {
|
||||
const url = new URL(evt.detail.path, window.location.origin);
|
||||
const viewMode = url.searchParams.get('view_mode');
|
||||
if (viewMode) {
|
||||
parkResults.setAttribute('data-view-mode', viewMode);
|
||||
handleAfterSwap(evt) {
|
||||
if (evt.detail.target.id === 'results-container') {
|
||||
this.initializeLazyLoading(evt.detail.target);
|
||||
this.updateResultsInfo(evt.detail.target);
|
||||
this.animateResults(evt.detail.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize lazy loading for images
|
||||
function initializeLazyLoading(container) {
|
||||
if (!('IntersectionObserver' in window)) return;
|
||||
handleHistoryRestore(evt) {
|
||||
const parkResults = document.getElementById('park-results');
|
||||
if (parkResults && evt.detail.path) {
|
||||
const url = new URL(evt.detail.path, window.location.origin);
|
||||
const viewMode = url.searchParams.get('view_mode');
|
||||
if (viewMode) {
|
||||
parkResults.setAttribute('data-view-mode', viewMode);
|
||||
}
|
||||
}
|
||||
|
||||
const imageObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.removeAttribute('data-src');
|
||||
imageObserver.unobserve(img);
|
||||
// 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 => {
|
||||
imageObserver.observe(img);
|
||||
});
|
||||
}
|
||||
|
||||
setupKeyboardNavigation() {
|
||||
// Tab navigation for filter cards
|
||||
document.addEventListener('keydown', (evt) => {
|
||||
if (evt.key === 'Tab' && evt.target.closest('.park-card')) {
|
||||
this.handleCardNavigation(evt);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
container.querySelectorAll('img[data-src]').forEach(img => {
|
||||
imageObserver.observe(img);
|
||||
});
|
||||
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 lazy loading after HTMX content swaps
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
initializeLazyLoading(evt.detail.target);
|
||||
// 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;
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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,39 +12,76 @@
|
||||
{% 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">
|
||||
{% if search_query %}
|
||||
No parks found matching "{{ search_query }}". Try adjusting your search terms.
|
||||
{% else %}
|
||||
No parks found matching your criteria. Try adjusting your filters.
|
||||
{% 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>.
|
||||
{% endif %}
|
||||
<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 %}
|
||||
<p class="mb-4">No parks found matching "{{ search_query }}". Try adjusting your search terms.</p>
|
||||
{% else %}
|
||||
<p class="mb-4">No parks found matching your criteria. Try adjusting your filters.</p>
|
||||
{% endif %}
|
||||
{% if user.is_authenticated %}
|
||||
<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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
154
parks/views.py
154
parks/views.py
@@ -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
|
||||
self.filterset = self.filter_class(self.request.GET, queryset=queryset)
|
||||
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)
|
||||
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
.park-card[data-view-mode="list"]:hover {
|
||||
background-color: #374151;
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,550 @@
|
||||
// Handle view mode persistence across HTMX requests
|
||||
document.addEventListener('htmx:configRequest', function(evt) {
|
||||
// Preserve view mode
|
||||
const parkResults = document.getElementById('park-results');
|
||||
if (parkResults) {
|
||||
const viewMode = parkResults.getAttribute('data-view-mode');
|
||||
if (viewMode) {
|
||||
evt.detail.parameters['view_mode'] = viewMode;
|
||||
/**
|
||||
* 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) {
|
||||
const viewMode = parkResults.getAttribute('data-view-mode');
|
||||
if (viewMode) {
|
||||
evt.detail.parameters['view_mode'] = viewMode;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve search terms
|
||||
const searchInput = document.querySelector('input[name="search"]');
|
||||
if (searchInput && searchInput.value) {
|
||||
evt.detail.parameters['search'] = searchInput.value;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Re-enable form elements
|
||||
this.toggleFormElements(true);
|
||||
|
||||
// Handle response timing
|
||||
const responseTime = Date.now() - this.lastRequestTime;
|
||||
if (responseTime > 3000) {
|
||||
this.showPerformanceWarning();
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve search terms
|
||||
const searchInput = document.getElementById('search');
|
||||
if (searchInput && searchInput.value) {
|
||||
evt.detail.parameters['search'] = searchInput.value;
|
||||
handleResponseError(evt) {
|
||||
this.hideLoadingIndicator(evt.detail.target);
|
||||
this.toggleFormElements(true);
|
||||
this.showErrorMessage('Failed to load results. Please try again.');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle loading states
|
||||
document.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
const target = evt.detail.target;
|
||||
if (target) {
|
||||
target.classList.add('htmx-requesting');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(evt) {
|
||||
const target = evt.detail.target;
|
||||
if (target) {
|
||||
target.classList.remove('htmx-requesting');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle history navigation
|
||||
document.addEventListener('htmx:historyRestore', function(evt) {
|
||||
const parkResults = document.getElementById('park-results');
|
||||
if (parkResults && evt.detail.path) {
|
||||
const url = new URL(evt.detail.path, window.location.origin);
|
||||
const viewMode = url.searchParams.get('view_mode');
|
||||
if (viewMode) {
|
||||
parkResults.setAttribute('data-view-mode', viewMode);
|
||||
handleAfterSwap(evt) {
|
||||
if (evt.detail.target.id === 'results-container') {
|
||||
this.initializeLazyLoading(evt.detail.target);
|
||||
this.updateResultsInfo(evt.detail.target);
|
||||
this.animateResults(evt.detail.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize lazy loading for images
|
||||
function initializeLazyLoading(container) {
|
||||
if (!('IntersectionObserver' in window)) return;
|
||||
handleHistoryRestore(evt) {
|
||||
const parkResults = document.getElementById('park-results');
|
||||
if (parkResults && evt.detail.path) {
|
||||
const url = new URL(evt.detail.path, window.location.origin);
|
||||
const viewMode = url.searchParams.get('view_mode');
|
||||
if (viewMode) {
|
||||
parkResults.setAttribute('data-view-mode', viewMode);
|
||||
}
|
||||
}
|
||||
|
||||
const imageObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.removeAttribute('data-src');
|
||||
imageObserver.unobserve(img);
|
||||
// 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 => {
|
||||
imageObserver.observe(img);
|
||||
});
|
||||
}
|
||||
|
||||
setupKeyboardNavigation() {
|
||||
// Tab navigation for filter cards
|
||||
document.addEventListener('keydown', (evt) => {
|
||||
if (evt.key === 'Tab' && evt.target.closest('.park-card')) {
|
||||
this.handleCardNavigation(evt);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
container.querySelectorAll('img[data-src]').forEach(img => {
|
||||
imageObserver.observe(img);
|
||||
});
|
||||
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 lazy loading after HTMX content swaps
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
initializeLazyLoading(evt.detail.target);
|
||||
// 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;
|
||||
}
|
||||
@@ -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()">
|
||||
|
||||
{# Active Filters Summary #}
|
||||
{% if applied_filters %}
|
||||
<div class="bg-blue-50 rounded-lg p-4 shadow-xs border border-blue-100">
|
||||
<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>
|
||||
</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">
|
||||
Clear All
|
||||
</a>
|
||||
{# 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 #}
|
||||
<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 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>
|
||||
<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
|
||||
</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-1">
|
||||
{{ field|add_field_classes }}
|
||||
<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>
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% if field.help_text %}
|
||||
<p class="mt-1 text-sm text-gray-500">{{ field.help_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
1
templates/search/components/filter_form.html
Symbolic link
1
templates/search/components/filter_form.html
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../core/search/components/filter_form.html
|
||||
Reference in New Issue
Block a user