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 %} -
+
-

Parks

+

Parks

-
+
View mode selection - -
{% if user.is_authenticated %} - + + + Add Park {% endif %} @@ -45,8 +47,10 @@ {% endblock %} {% block filter_section %} -
-
+ +
+ {# Enhanced Search Section #} +
+
+ + + +
+ @keydown.escape="query = ''" + @focus="$event.target.select()"> + + + -
@@ -87,35 +107,412 @@
+ 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">
-
+ {# Active Filter Chips Section #} +
+
+
+

Active Filters

+ +
+
+ +
+
+
+ + {# Filter Panel #} +
-

Filters

+
+

Filters

+ +
+
+ hx-indicator="#main-loading-indicator" + class="mt-4" + @htmx:beforeRequest="onFilterRequest()" + @htmx:afterRequest="onFilterResponse($event)"> - {% include "search/components/filter_form.html" with filter=filter %} + {% include "core/search/components/filter_form.html" with filter=filter %}
+ +{# Main Loading Indicator #} +
+
+ + + + + Updating results... +
+
+ + {% endblock %} {% block results_list %} -
- {% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %} + + {# Results Header with Count and Sort #} +
+
+
+

+ Parks + {% if parks %} + + ({{ parks|length }}{% if parks.has_other_pages %} of {{ parks.paginator.count }}{% endif %} found) + + {% endif %} +

+ + {# 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 %} + + Filtered + + {% endif %} +
+ + {# Sort Options #} +
+ + +
+
+
+ + {# Results Content #} +
+ {% if parks %} + {% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %} + {% else %} + {# No Results Found #} +
+
+ + + +
+ +

No parks found

+ + {% if request.GET.search %} +

+ No parks match your search for "{{ request.GET.search }}". +
Try searching with different keywords or check your spelling. +

+ {% elif request.GET.park_type or request.GET.has_coasters or request.GET.min_rating or request.GET.big_parks_only %} +

+ No parks match your current filter criteria. +
Try adjusting your filters or removing some restrictions. +

+ {% else %} +

+ No parks are currently available in the database. +

+ {% endif %} + +
+ {% 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 %} + +
+ + + + + View all parks + + {% endif %} + + {% if user.is_authenticated %} + + {% endif %} +
+
+ {% endif %} +
+ + {% endblock %} \ No newline at end of file diff --git a/parks/templates/parks/partials/park_list_item.html b/parks/templates/parks/partials/park_list_item.html index 23c204d2..c5412ed4 100644 --- a/parks/templates/parks/partials/park_list_item.html +++ b/parks/templates/parks/partials/park_list_item.html @@ -1,9 +1,8 @@ {% load static %} -{% load filter_utils %} {% if error %}
-
+
@@ -13,39 +12,76 @@ {% else %}
{% for park in object_list|default:parks %} -
-
-

- +
+
+

+ {{ park.name }}

-
- +
+ {{ park.get_status_display }}
- {% if park.owner %} -
- - {{ park.owner.name }} - + {% if park.operator %} +
+ {{ park.operator.name }} +
+ {% endif %} + + {% if park.description %} +

+ {{ park.description|truncatewords:20 }} +

+ {% endif %} + + {% if park.ride_count or park.coaster_count %} +
+ {% if park.ride_count %} + + + + + {{ park.ride_count }} rides + + {% endif %} + {% if park.coaster_count %} + + + + + {{ park.coaster_count }} coasters + + {% endif %}
{% endif %}
{% empty %} -
- {% 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 add a new park. - {% endif %} +
+
+ + + +
+

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 add a new park.

+ {% endif %} +
{% endfor %}
diff --git a/parks/templates/parks/partials/search_suggestions.html b/parks/templates/parks/partials/search_suggestions.html index e9799105..c8402573 100644 --- a/parks/templates/parks/partials/search_suggestions.html +++ b/parks/templates/parks/partials/search_suggestions.html @@ -1,4 +1,3 @@ -{% load filter_utils %} {% if suggestions %}
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) diff --git a/staticfiles/parks/css/search.css b/staticfiles/parks/css/search.css index f887f5ae..f6d0cc92 100644 --- a/staticfiles/parks/css/search.css +++ b/staticfiles/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/staticfiles/parks/js/search.js b/staticfiles/parks/js/search.js index 66895b08..07c30c45 100644 --- a/staticfiles/parks/js/search.js +++ b/staticfiles/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/templates/core/search/components/filter_form.html b/templates/core/search/components/filter_form.html index 91745891..69013d08 100644 --- a/templates/core/search/components/filter_form.html +++ b/templates/core/search/components/filter_form.html @@ -1,20 +1,122 @@ +{# Enhanced filter form with modern UI/UX design - timestamp: 2025-08-21 #} {% load static %} -{% load filter_utils %} -
+
+ {# Quick Filter Presets - Enhanced Design #} +
+
+
+
+
+

Quick Filters

+

Find parks instantly with preset filters

+
+
+
+
+ Live filtering +
+
+ +
+ + + + + + + +
+
+ {# Mobile Filter Toggle #} -
-
{# Filter Form #} -
+ 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 %} -
-
-
-

Active Filters

-

{{ applied_filters|length }} filter{{ applied_filters|length|pluralize }} applied

-
- - Clear All - + {# Loading Indicator #} +
+
+ + + + + Updating results... +
+
+ + {# Active Filters Summary #} +
+
+
+

Active Filters

+

+
+
- {% endif %} {# Filter Groups #} -
- {% for fieldset in filter.form|groupby_filters %} +
{# Group Header #} - {# Group Content #} -
- {% for field in fieldset.fields %} +
+ {% for field in filter.form %}
-
- {% endfor %}
{# Mobile Apply Button #}
-
-{# Required Scripts #} +{# Enhanced Scripts #} \ No newline at end of file diff --git a/templates/core/search/layouts/filtered_list.html b/templates/core/search/layouts/filtered_list.html index 2c0c14ab..d20bbfe3 100644 --- a/templates/core/search/layouts/filtered_list.html +++ b/templates/core/search/layouts/filtered_list.html @@ -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 %}
-
+
{# Filters Sidebar #} -
- {% include "search/components/filter_form.html" %} +
+ {% block filter_section %} + + {% include "core/search/components/filter_form.html" %} + {% endblock %}
{# Results Section #}
{# Result count and sorting #} -
-
-
-

- {{ view.model|model_name_plural|title }} - ({{ page_obj.paginator.count }} found) +
+
+
+

+ Parks + ({{ page_obj.paginator.count }} found)

{% block list_actions %} @@ -32,14 +35,14 @@ {# Results list #} {% block results_list %} -
+
{% include results_template|default:"search/partials/generic_results.html" %}
{% endblock %} {# Pagination #} {% if is_paginated %} -
+
{% include "search/components/pagination.html" %}
{% endif %} diff --git a/templates/parks/park_list.html b/templates/parks/park_list.html deleted file mode 100644 index efda2d33..00000000 --- a/templates/parks/park_list.html +++ /dev/null @@ -1,225 +0,0 @@ -{% extends 'base/base.html' %} -{% load static %} - -{% block title %}Parks - ThrillWiki{% endblock %} - -{% block content %} -
-
-

All Parks

-
-
- - -
-
-
- - -
- - -
- - - -
    - -
-
- - -
- - - -
    - -
-
- - -
- - - -
    - -
-
- - - {% for status in current_filters.statuses %} - - {% endfor %} -
- - -
- - - - - - - -
-
- - -
- {% include "parks/partials/park_list.html" %} -
-
- - -{% endblock %} diff --git a/templates/search/components/filter_form.html b/templates/search/components/filter_form.html new file mode 120000 index 00000000..ad070ead --- /dev/null +++ b/templates/search/components/filter_form.html @@ -0,0 +1 @@ +../../../core/search/components/filter_form.html \ No newline at end of file