From 652ea149bd2d8bbbc974995f001bac98b465ac2f Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Wed, 20 Aug 2025 21:20:10 -0400 Subject: [PATCH] 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. --- parks/filters.py | 151 ++++- parks/migrations/0001_add_filter_indexes.py | 62 ++ parks/migrations/0005_merge_20250820_2020.py | 13 + parks/services/__init__.py | 3 +- parks/services/filter_service.py | 304 +++++++++ parks/static/parks/css/search.css | 301 ++++++++- parks/static/parks/js/search.js | 597 ++++++++++++++++-- parks/templates/parks/park_list.html | 457 +++++++++++++- .../parks/partials/park_list_item.html | 80 ++- .../parks/partials/search_suggestions.html | 1 - parks/views.py | 154 ++++- staticfiles/parks/css/search.css | 301 ++++++++- staticfiles/parks/js/search.js | 597 ++++++++++++++++-- .../core/search/components/filter_form.html | 463 ++++++++++++-- .../core/search/layouts/filtered_list.html | 29 +- templates/parks/park_list.html | 225 ------- templates/search/components/filter_form.html | 1 + 17 files changed, 3224 insertions(+), 515 deletions(-) create mode 100644 parks/migrations/0001_add_filter_indexes.py create mode 100644 parks/migrations/0005_merge_20250820_2020.py create mode 100644 parks/services/filter_service.py delete mode 100644 templates/parks/park_list.html create mode 120000 templates/search/components/filter_form.html diff --git a/parks/filters.py b/parks/filters.py index 793dff93..b468a625 100644 --- a/parks/filters.py +++ b/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. diff --git a/parks/migrations/0001_add_filter_indexes.py b/parks/migrations/0001_add_filter_indexes.py new file mode 100644 index 00000000..36d02ac7 --- /dev/null +++ b/parks/migrations/0001_add_filter_indexes.py @@ -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;", + ), + ] diff --git a/parks/migrations/0005_merge_20250820_2020.py b/parks/migrations/0005_merge_20250820_2020.py new file mode 100644 index 00000000..54b92f6f --- /dev/null +++ b/parks/migrations/0005_merge_20250820_2020.py @@ -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 = [] diff --git a/parks/services/__init__.py b/parks/services/__init__.py index e3d0fc03..af0b3879 100644 --- a/parks/services/__init__.py +++ b/parks/services/__init__.py @@ -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"] diff --git a/parks/services/filter_service.py b/parks/services/filter_service.py new file mode 100644 index 00000000..1a122a27 --- /dev/null +++ b/parks/services/filter_service.py @@ -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 diff --git a/parks/static/parks/css/search.css b/parks/static/parks/css/search.css index f887f5ae..f6d0cc92 100644 --- a/parks/static/parks/css/search.css +++ b/parks/static/parks/css/search.css @@ -1,4 +1,4 @@ -/* Loading states */ +/* Enhanced Loading states */ .htmx-request .htmx-indicator { opacity: 1; } @@ -10,23 +10,140 @@ transition: opacity 200ms ease-in-out; } +/* Loading pulse animation */ +@keyframes loading-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.htmx-request { + animation: loading-pulse 1.5s ease-in-out infinite; +} + /* Results container transitions */ #park-results { - transition: opacity 200ms ease-in-out; + transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); } .htmx-request #park-results { opacity: 0.7; + transform: scale(0.98); } .htmx-settling #park-results { opacity: 1; + transform: scale(1); +} + +/* Filter UI Enhancements */ +.quick-filter-btn { + @apply inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 ease-in-out; + @apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply transform hover:scale-105 active:scale-95; + @apply border border-transparent; +} + +.quick-filter-btn:hover { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.filter-count { + @apply text-xs opacity-75 ml-1; +} + +/* Filter Chips Styling */ +.filter-chip { + @apply inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-800; + @apply dark:bg-blue-800 dark:text-blue-100 transition-all duration-200; + animation: slideIn 0.3s ease-out; +} + +.filter-chip:hover { + @apply bg-blue-200 dark:bg-blue-700; + transform: translateY(-1px); +} + +.filter-chip .remove-btn { + @apply ml-2 inline-flex items-center justify-center w-4 h-4; + @apply text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100; + @apply focus:outline-none transition-colors duration-150; +} + +.filter-chip .remove-btn:hover { + transform: scale(1.1); +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-10px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Enhanced Search Input */ +.search-input { + @apply transition-all duration-200 ease-in-out; +} + +.search-input:focus { + @apply ring-2 ring-blue-500 border-blue-500; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +/* Enhanced Form Controls */ +.filter-field select, +.filter-field input[type="text"], +.filter-field input[type="number"], +.filter-field input[type="search"], +.form-field-wrapper input, +.form-field-wrapper select { + @apply transition-all duration-200 ease-in-out; + @apply border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white; + @apply focus:border-blue-500 focus:ring-blue-500; + @apply rounded-md shadow-sm; +} + +.filter-field select:focus, +.filter-field input:focus, +.form-field-wrapper input:focus, +.form-field-wrapper select:focus { + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.filter-field input[type="checkbox"], +.form-field-wrapper input[type="checkbox"] { + @apply rounded transition-colors duration-200; + @apply text-blue-600 focus:ring-blue-500; + @apply border-gray-300 dark:border-gray-600; +} + +/* Enhanced Status Indicators */ +.status-indicator { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + animation: fadeIn 0.3s ease-out; +} + +.status-indicator.filtered { + @apply bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100; +} + +.status-indicator.loading { + @apply bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } } /* Grid/List transitions */ .park-card { - transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); position: relative; background-color: white; - border-radius: 0.5rem; + border-radius: 0.75rem; border: 1px solid #e5e7eb; } @@ -36,8 +153,8 @@ flex-direction: column; } .park-card[data-view-mode="grid"]:hover { - transform: translateY(-2px); - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + transform: translateY(-4px); + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); } /* List view styles */ @@ -48,12 +165,14 @@ } .park-card[data-view-mode="list"]:hover { background-color: #f9fafb; + transform: translateX(4px); } /* Image containers */ .park-card .image-container { position: relative; overflow: hidden; + border-radius: 0.5rem; } .park-card[data-view-mode="grid"] .image-container { aspect-ratio: 16 / 9; @@ -73,23 +192,26 @@ min-width: 0; /* Enables text truncation in flex child */ } -/* Status badges */ +/* Enhanced Status badges */ .park-card .status-badge { - transition: all 150ms ease-in-out; + transition: all 200ms ease-in-out; + border-radius: 9999px; + font-weight: 500; } .park-card:hover .status-badge { transform: scale(1.05); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } /* Images */ .park-card img { - transition: transform 200ms ease-in-out; + transition: transform 300ms ease-in-out; object-fit: cover; width: 100%; height: 100%; } .park-card:hover img { - transform: scale(1.05); + transform: scale(1.08); } /* Placeholders for missing images */ @@ -97,6 +219,7 @@ background: linear-gradient(110deg, #ececec 8%, #f5f5f5 18%, #ececec 33%); background-size: 200% 100%; animation: shimmer 1.5s linear infinite; + border-radius: 0.5rem; } @keyframes shimmer { @@ -105,7 +228,57 @@ } } -/* Dark mode */ +/* Enhanced No Results State */ +.no-results { + @apply text-center py-12; + animation: fadeInUp 0.5s ease-out; +} + +.no-results-icon { + @apply mx-auto w-24 h-24 text-gray-400 dark:text-gray-500 mb-6; + animation: float 3s ease-in-out infinite; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes float { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-10px); } +} + +/* Enhanced Buttons */ +.btn-enhanced { + @apply transition-all duration-200 ease-in-out; + @apply transform hover:scale-105 active:scale-95; + @apply focus:outline-none focus:ring-2 focus:ring-offset-2; +} + +.btn-enhanced:hover { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +/* Tooltip Styles */ +.tooltip { + @apply absolute z-50 px-2 py-1 text-xs font-medium text-white bg-gray-900 rounded shadow-lg; + @apply dark:bg-gray-700 dark:text-gray-200; + animation: tooltipFadeIn 0.2s ease-out; +} + +@keyframes tooltipFadeIn { + from { opacity: 0; transform: scale(0.8); } + to { opacity: 1; transform: scale(1); } +} + +/* Enhanced Dark mode */ @media (prefers-color-scheme: dark) { .park-card { background-color: #1f2937; @@ -128,7 +301,111 @@ background: linear-gradient(110deg, #2d3748 8%, #374151 18%, #2d3748 33%); } + .search-input { + @apply dark:bg-gray-700 dark:border-gray-600 dark:text-white; + } + + .quick-filter-btn:not(.active) { + @apply dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600; + } + + /* Enhanced filter panel styling */ + .filter-container { + @apply dark:text-gray-200; + } + + .filter-field label { + @apply dark:text-gray-300; + } +} + +/* Additional enhancements for better visual hierarchy */ +.filter-container h3 { + @apply font-semibold tracking-wide; +} + +.filter-field { + @apply mb-4; +} + +.filter-field label { + @apply font-medium text-sm; +} + +/* Status badge improvements */ +.status-badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + @apply transition-all duration-200; +} + +/* Loading state improvements */ +.htmx-request .filter-container { + @apply pointer-events-none; +} + +.htmx-request .quick-filter-btn { + @apply opacity-75; +} + +/* Mobile Responsive Enhancements */ +@media (max-width: 768px) { + .quick-filter-btn { + @apply text-xs px-2 py-1; + } + + .filter-chip { + @apply text-xs px-2 py-1; + } + + .park-card[data-view-mode="grid"]:hover { + transform: translateY(-2px); + } + .park-card[data-view-mode="list"]:hover { - background-color: #374151; + transform: none; + } +} + +/* Accessibility Enhancements */ +@media (prefers-reduced-motion: reduce) { + .park-card, + .quick-filter-btn, + .filter-chip, + .btn-enhanced { + transition: none; + animation: none; + } + + .park-card:hover { + transform: none; + } + + .no-results-icon { + animation: none; + } +} + +/* Focus States for Keyboard Navigation */ +.park-card:focus-within { + @apply ring-2 ring-blue-500 ring-offset-2; +} + +.quick-filter-btn:focus, +.filter-chip .remove-btn:focus { + @apply ring-2 ring-blue-500 ring-offset-2; +} + +/* High Contrast Mode Support */ +@media (prefers-contrast: high) { + .park-card { + border-width: 2px; + } + + .filter-chip { + border: 2px solid currentColor; + } + + .quick-filter-btn { + border: 2px solid currentColor; } } \ No newline at end of file diff --git a/parks/static/parks/js/search.js b/parks/static/parks/js/search.js index 66895b08..07c30c45 100644 --- a/parks/static/parks/js/search.js +++ b/parks/static/parks/js/search.js @@ -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); } } -}); + + 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); + } + } + + // 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; -// Initialize lazy loading for images -function initializeLazyLoading(container) { - 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' + }); - 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); + 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 = ` + Search is taking longer than expected... + + + + `; + + 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 = ` + ${message} + + + + + `; + + 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); -}); \ No newline at end of file +// 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; +} \ No newline at end of file diff --git a/parks/templates/parks/park_list.html b/parks/templates/parks/park_list.html index 7843a2a9..77122a71 100644 --- a/parks/templates/parks/park_list.html +++ b/parks/templates/parks/park_list.html @@ -1,30 +1,29 @@ -{% 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 %} -
+ No parks match your search for "{{ request.GET.search }}".
+
Try searching with different keywords or check your spelling.
+
+ No parks match your current filter criteria.
+
Try adjusting your filters or removing some restrictions.
+
+ No parks are currently available in the database. +
+ {% endif %} + ++ {{ park.description|truncatewords:20 }} +
+ {% endif %} + + {% if park.ride_count or park.coaster_count %} +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 add a new park.
+ {% endif %} +Find parks instantly with preset filters
+