mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 09:51:09 -05:00
feat: Implement comprehensive ride filtering system with API integration
- Added `useRideFiltering` composable for managing ride filters and fetching rides from the API. - Created `useParkRideFiltering` for park-specific ride filtering. - Developed `useTheme` composable for theme management with localStorage support. - Established `rideFiltering` Pinia store for centralized state management of ride filters and UI state. - Defined enhanced filter types in `filters.ts` for better type safety and clarity. - Built `RideFilteringPage.vue` to provide a user interface for filtering rides with responsive design. - Integrated filter sidebar and ride list display components for a cohesive user experience. - Added support for filter presets and search suggestions. - Implemented computed properties for active filters, average ratings, and operating counts.
This commit is contained in:
@@ -675,15 +675,16 @@ class RideViewSet(ModelViewSet):
|
||||
if park_slug:
|
||||
# For nested endpoints, use the dedicated park selector
|
||||
from apps.rides.selectors import rides_in_park
|
||||
|
||||
return rides_in_park(park_slug=park_slug)
|
||||
|
||||
|
||||
# For global endpoints, parse filter parameters and use general selector
|
||||
filter_serializer = RideFilterInputSerializer(
|
||||
data=self.request.query_params # type: ignore[attr-defined]
|
||||
)
|
||||
filter_serializer.is_valid(raise_exception=True)
|
||||
filters = filter_serializer.validated_data
|
||||
|
||||
|
||||
return ride_list_for_display(filters=filters) # type: ignore[arg-type]
|
||||
|
||||
# For other actions, return base queryset
|
||||
|
||||
@@ -9,16 +9,18 @@ from .views.entity_search import (
|
||||
QuickEntitySuggestionView,
|
||||
)
|
||||
|
||||
app_name = 'core'
|
||||
app_name = "core"
|
||||
|
||||
# Entity search endpoints
|
||||
entity_patterns = [
|
||||
path('search/', EntityFuzzySearchView.as_view(), name='entity_fuzzy_search'),
|
||||
path('not-found/', EntityNotFoundView.as_view(), name='entity_not_found'),
|
||||
path('suggestions/', QuickEntitySuggestionView.as_view(), name='entity_suggestions'),
|
||||
path("search/", EntityFuzzySearchView.as_view(), name="entity_fuzzy_search"),
|
||||
path("not-found/", EntityNotFoundView.as_view(), name="entity_not_found"),
|
||||
path(
|
||||
"suggestions/", QuickEntitySuggestionView.as_view(), name="entity_suggestions"
|
||||
),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
# Entity fuzzy matching and search endpoints
|
||||
path('entities/', include(entity_patterns)),
|
||||
]
|
||||
path("entities/", include(entity_patterns)),
|
||||
]
|
||||
|
||||
23
backend/apps/rides/api_urls.py
Normal file
23
backend/apps/rides/api_urls.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.urls import path
|
||||
from . import api_views
|
||||
|
||||
app_name = "rides_api"
|
||||
|
||||
urlpatterns = [
|
||||
# Main ride listing and filtering API
|
||||
path("rides/", api_views.RideListAPIView.as_view(), name="ride_list"),
|
||||
# Filter options endpoint
|
||||
path("filter-options/", api_views.get_filter_options, name="filter_options"),
|
||||
# Search endpoints
|
||||
path("search/companies/", api_views.search_companies_api, name="search_companies"),
|
||||
path(
|
||||
"search/ride-models/",
|
||||
api_views.search_ride_models_api,
|
||||
name="search_ride_models",
|
||||
),
|
||||
path(
|
||||
"search/suggestions/",
|
||||
api_views.get_search_suggestions_api,
|
||||
name="search_suggestions",
|
||||
),
|
||||
]
|
||||
363
backend/apps/rides/api_views.py
Normal file
363
backend/apps/rides/api_views.py
Normal file
@@ -0,0 +1,363 @@
|
||||
from rest_framework import generics
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Count
|
||||
from django.http import Http404
|
||||
|
||||
from .models.rides import Ride, Categories, RideModel
|
||||
from .models.company import Company
|
||||
from .forms.search import MasterFilterForm
|
||||
from .services.search import RideSearchService
|
||||
from .serializers import RideSerializer
|
||||
from apps.parks.models import Park
|
||||
|
||||
|
||||
class RidePagination(PageNumberPagination):
|
||||
"""Custom pagination for ride API"""
|
||||
|
||||
page_size = 24
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class RideListAPIView(generics.ListAPIView):
|
||||
"""API endpoint for listing and filtering rides"""
|
||||
|
||||
serializer_class = RideSerializer
|
||||
pagination_class = RidePagination
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get filtered rides using the advanced search service"""
|
||||
# Initialize search service
|
||||
search_service = RideSearchService()
|
||||
|
||||
# Parse filters from request
|
||||
filter_form = MasterFilterForm(self.request.query_params)
|
||||
|
||||
# Apply park context if available
|
||||
park = None
|
||||
park_slug = self.request.query_params.get("park_slug")
|
||||
if park_slug:
|
||||
try:
|
||||
park = get_object_or_404(Park, slug=park_slug)
|
||||
except Http404:
|
||||
park = None
|
||||
|
||||
if filter_form.is_valid():
|
||||
# Use advanced search service
|
||||
queryset = search_service.search_rides(
|
||||
filters=filter_form.get_filter_dict(), park=park
|
||||
)
|
||||
else:
|
||||
# Fallback to basic queryset with park filter
|
||||
queryset = (
|
||||
Ride.objects.all()
|
||||
.select_related("park", "ride_model", "ride_model__manufacturer")
|
||||
.prefetch_related("photos")
|
||||
)
|
||||
if park:
|
||||
queryset = queryset.filter(park=park)
|
||||
|
||||
return queryset
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Enhanced list response with filter metadata"""
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
# Get pagination
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
paginated_response = self.get_paginated_response(serializer.data)
|
||||
|
||||
# Add filter metadata
|
||||
filter_form = MasterFilterForm(request.query_params)
|
||||
if filter_form.is_valid():
|
||||
active_filters = filter_form.get_filter_summary()
|
||||
has_filters = filter_form.has_active_filters()
|
||||
else:
|
||||
active_filters = {}
|
||||
has_filters = False
|
||||
|
||||
# Add counts
|
||||
park_slug = request.query_params.get("park_slug")
|
||||
if park_slug:
|
||||
try:
|
||||
park = get_object_or_404(Park, slug=park_slug)
|
||||
total_rides = Ride.objects.filter(park=park).count()
|
||||
except Http404:
|
||||
total_rides = Ride.objects.count()
|
||||
else:
|
||||
total_rides = Ride.objects.count()
|
||||
|
||||
filtered_count = queryset.count()
|
||||
|
||||
# Enhance response with metadata
|
||||
paginated_response.data.update(
|
||||
{
|
||||
"filter_metadata": {
|
||||
"active_filters": active_filters,
|
||||
"has_filters": has_filters,
|
||||
"total_rides": total_rides,
|
||||
"filtered_count": filtered_count,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return paginated_response
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
def get_filter_options(request):
|
||||
"""API endpoint to get all filter options for the frontend"""
|
||||
|
||||
# Get park context if provided
|
||||
park_slug = request.query_params.get("park_slug")
|
||||
park = None
|
||||
if park_slug:
|
||||
try:
|
||||
park = get_object_or_404(Park, slug=park_slug)
|
||||
except Http404:
|
||||
park = None
|
||||
|
||||
# Base queryset
|
||||
rides_queryset = Ride.objects.all()
|
||||
if park:
|
||||
rides_queryset = rides_queryset.filter(park=park)
|
||||
|
||||
# Categories
|
||||
categories = [{"code": code, "name": name} for code, name in Categories]
|
||||
|
||||
# Manufacturers
|
||||
manufacturer_ids = rides_queryset.values_list(
|
||||
"ride_model__manufacturer_id", flat=True
|
||||
).distinct()
|
||||
manufacturers = list(
|
||||
Company.objects.filter(
|
||||
id__in=manufacturer_ids, roles__contains=["MANUFACTURER"]
|
||||
)
|
||||
.values("id", "name")
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
# Designers
|
||||
designer_ids = rides_queryset.values_list("designer_id", flat=True).distinct()
|
||||
designers = list(
|
||||
Company.objects.filter(id__in=designer_ids, roles__contains=["DESIGNER"])
|
||||
.values("id", "name")
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
# Parks (for global view)
|
||||
parks = []
|
||||
if not park:
|
||||
parks = list(
|
||||
Park.objects.filter(
|
||||
id__in=rides_queryset.values_list("park_id", flat=True).distinct()
|
||||
)
|
||||
.values("id", "name", "slug")
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
# Ride models
|
||||
ride_model_ids = rides_queryset.values_list("ride_model_id", flat=True).distinct()
|
||||
ride_models = list(
|
||||
RideModel.objects.filter(id__in=ride_model_ids)
|
||||
.select_related("manufacturer")
|
||||
.values("id", "name", "manufacturer__name")
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
# Get value ranges for numeric fields
|
||||
from django.db.models import Min, Max
|
||||
|
||||
ranges = rides_queryset.aggregate(
|
||||
height_min=Min("height_ft"),
|
||||
height_max=Max("height_ft"),
|
||||
speed_min=Min("max_speed_mph"),
|
||||
speed_max=Max("max_speed_mph"),
|
||||
capacity_min=Min("hourly_capacity"),
|
||||
capacity_max=Max("hourly_capacity"),
|
||||
duration_min=Min("duration_seconds"),
|
||||
duration_max=Max("duration_seconds"),
|
||||
)
|
||||
|
||||
# Get date ranges
|
||||
date_ranges = rides_queryset.aggregate(
|
||||
opening_min=Min("opening_date"),
|
||||
opening_max=Max("opening_date"),
|
||||
closing_min=Min("closing_date"),
|
||||
closing_max=Max("closing_date"),
|
||||
)
|
||||
|
||||
data = {
|
||||
"categories": categories,
|
||||
"manufacturers": manufacturers,
|
||||
"designers": designers,
|
||||
"parks": parks,
|
||||
"ride_models": ride_models,
|
||||
"ranges": {
|
||||
"height": {
|
||||
"min": ranges["height_min"] or 0,
|
||||
"max": ranges["height_max"] or 500,
|
||||
"step": 5,
|
||||
},
|
||||
"speed": {
|
||||
"min": ranges["speed_min"] or 0,
|
||||
"max": ranges["speed_max"] or 150,
|
||||
"step": 5,
|
||||
},
|
||||
"capacity": {
|
||||
"min": ranges["capacity_min"] or 0,
|
||||
"max": ranges["capacity_max"] or 3000,
|
||||
"step": 100,
|
||||
},
|
||||
"duration": {
|
||||
"min": ranges["duration_min"] or 0,
|
||||
"max": ranges["duration_max"] or 600,
|
||||
"step": 10,
|
||||
},
|
||||
},
|
||||
"date_ranges": {
|
||||
"opening": {
|
||||
"min": (
|
||||
date_ranges["opening_min"].isoformat()
|
||||
if date_ranges["opening_min"]
|
||||
else None
|
||||
),
|
||||
"max": (
|
||||
date_ranges["opening_max"].isoformat()
|
||||
if date_ranges["opening_max"]
|
||||
else None
|
||||
),
|
||||
},
|
||||
"closing": {
|
||||
"min": (
|
||||
date_ranges["closing_min"].isoformat()
|
||||
if date_ranges["closing_min"]
|
||||
else None
|
||||
),
|
||||
"max": (
|
||||
date_ranges["closing_max"].isoformat()
|
||||
if date_ranges["closing_max"]
|
||||
else None
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
def search_companies_api(request):
|
||||
"""API endpoint for company search"""
|
||||
query = request.query_params.get("q", "").strip()
|
||||
role = request.query_params.get("role", "").upper()
|
||||
|
||||
companies = Company.objects.all().order_by("name")
|
||||
if role:
|
||||
companies = companies.filter(roles__contains=[role])
|
||||
if query:
|
||||
companies = companies.filter(name__icontains=query)
|
||||
|
||||
companies = companies[:10]
|
||||
|
||||
data = [
|
||||
{"id": company.id, "name": company.name, "roles": company.roles}
|
||||
for company in companies
|
||||
]
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
def search_ride_models_api(request):
|
||||
"""API endpoint for ride model search"""
|
||||
query = request.query_params.get("q", "").strip()
|
||||
manufacturer_id = request.query_params.get("manufacturer")
|
||||
|
||||
ride_models = RideModel.objects.select_related("manufacturer").order_by("name")
|
||||
if query:
|
||||
ride_models = ride_models.filter(name__icontains=query)
|
||||
if manufacturer_id:
|
||||
ride_models = ride_models.filter(manufacturer_id=manufacturer_id)
|
||||
|
||||
ride_models = ride_models[:10]
|
||||
|
||||
data = [
|
||||
{
|
||||
"id": model.id,
|
||||
"name": model.name,
|
||||
"manufacturer": (
|
||||
{"id": model.manufacturer.id, "name": model.manufacturer.name}
|
||||
if model.manufacturer
|
||||
else None
|
||||
),
|
||||
}
|
||||
for model in ride_models
|
||||
]
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
def get_search_suggestions_api(request):
|
||||
"""API endpoint for smart search suggestions"""
|
||||
query = request.query_params.get("q", "").strip().lower()
|
||||
suggestions = []
|
||||
|
||||
if query:
|
||||
# Get common ride names
|
||||
matching_names = (
|
||||
Ride.objects.filter(name__icontains=query)
|
||||
.values("name")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:3]
|
||||
)
|
||||
|
||||
for match in matching_names:
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "ride",
|
||||
"text": match["name"],
|
||||
"count": match["count"],
|
||||
}
|
||||
)
|
||||
|
||||
# Get matching parks
|
||||
from django.db.models import Q
|
||||
|
||||
matching_parks = Park.objects.filter(
|
||||
Q(name__icontains=query) | Q(location__city__icontains=query)
|
||||
)[:3]
|
||||
|
||||
for park in matching_parks:
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "park",
|
||||
"text": park.name,
|
||||
"location": park.location.city if park.location else None,
|
||||
"slug": park.slug,
|
||||
}
|
||||
)
|
||||
|
||||
# Add category matches
|
||||
for code, name in Categories:
|
||||
if query in name.lower():
|
||||
ride_count = Ride.objects.filter(category=code).count()
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "category",
|
||||
"code": code,
|
||||
"text": name,
|
||||
"count": ride_count,
|
||||
}
|
||||
)
|
||||
|
||||
return Response({"suggestions": suggestions, "query": query})
|
||||
591
backend/apps/rides/forms/search.py
Normal file
591
backend/apps/rides/forms/search.py
Normal file
@@ -0,0 +1,591 @@
|
||||
"""
|
||||
Ride Search and Filter Forms
|
||||
|
||||
Forms for the comprehensive ride filtering system with 8 categories:
|
||||
1. Search text
|
||||
2. Basic info
|
||||
3. Dates
|
||||
4. Height/safety
|
||||
5. Performance
|
||||
6. Relationships
|
||||
7. Roller coaster
|
||||
8. Company
|
||||
|
||||
Each form handles validation and provides clean data for the RideSearchService.
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from typing import Dict, Any
|
||||
|
||||
from apps.parks.models import Park, ParkArea
|
||||
from apps.rides.models.company import Company
|
||||
from apps.rides.models.rides import RideModel
|
||||
|
||||
|
||||
class BaseFilterForm(forms.Form):
|
||||
"""Base form with common functionality for all filter forms."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Add common CSS classes
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({"class": "form-input"})
|
||||
|
||||
|
||||
class SearchTextForm(BaseFilterForm):
|
||||
"""Form for text-based search filters."""
|
||||
|
||||
global_search = forms.CharField(
|
||||
required=False,
|
||||
max_length=255,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": "Search rides, parks, manufacturers...",
|
||||
"class": (
|
||||
"w-full px-4 py-2 border border-gray-300 "
|
||||
"dark:border-gray-600 rounded-lg focus:ring-2 "
|
||||
"focus:ring-blue-500 focus:border-transparent "
|
||||
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
),
|
||||
"autocomplete": "off",
|
||||
"hx-get": "",
|
||||
"hx-trigger": "input changed delay:300ms",
|
||||
"hx-target": "#search-results",
|
||||
"hx-indicator": "#search-loading",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
name_search = forms.CharField(
|
||||
required=False,
|
||||
max_length=255,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": "Search by ride name...",
|
||||
"class": (
|
||||
"w-full px-3 py-2 border border-gray-300 "
|
||||
"dark:border-gray-600 rounded-md focus:ring-2 "
|
||||
"focus:ring-blue-500 focus:border-transparent "
|
||||
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
description_search = forms.CharField(
|
||||
required=False,
|
||||
max_length=255,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": "Search in descriptions...",
|
||||
"class": (
|
||||
"w-full px-3 py-2 border border-gray-300 "
|
||||
"dark:border-gray-600 rounded-md focus:ring-2 "
|
||||
"focus:ring-blue-500 focus:border-transparent "
|
||||
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BasicInfoForm(BaseFilterForm):
|
||||
"""Form for basic ride information filters."""
|
||||
|
||||
CATEGORY_CHOICES = [
|
||||
("", "All Categories"),
|
||||
("roller_coaster", "Roller Coaster"),
|
||||
("water_ride", "Water Ride"),
|
||||
("flat_ride", "Flat Ride"),
|
||||
("dark_ride", "Dark Ride"),
|
||||
("kiddie_ride", "Kiddie Ride"),
|
||||
("transport", "Transport"),
|
||||
("show", "Show"),
|
||||
("other", "Other"),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
("", "All Statuses"),
|
||||
("operating", "Operating"),
|
||||
("closed_temporary", "Temporarily Closed"),
|
||||
("closed_permanent", "Permanently Closed"),
|
||||
("under_construction", "Under Construction"),
|
||||
("announced", "Announced"),
|
||||
("rumored", "Rumored"),
|
||||
]
|
||||
|
||||
category = forms.MultipleChoiceField(
|
||||
choices=CATEGORY_CHOICES[1:], # Exclude "All Categories"
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "grid grid-cols-2 gap-2"}),
|
||||
)
|
||||
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=STATUS_CHOICES[1:], # Exclude "All Statuses"
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
|
||||
park = forms.ModelMultipleChoiceField(
|
||||
queryset=Park.objects.all(),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
|
||||
),
|
||||
)
|
||||
|
||||
park_area = forms.ModelMultipleChoiceField(
|
||||
queryset=ParkArea.objects.all(),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DateRangeWidget(forms.MultiWidget):
|
||||
"""Custom widget for date range inputs."""
|
||||
|
||||
def __init__(self, attrs=None):
|
||||
widgets = [
|
||||
forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"px-3 py-2 border border-gray-300 "
|
||||
"dark:border-gray-600 rounded-md focus:ring-2 "
|
||||
"focus:ring-blue-500 focus:border-transparent "
|
||||
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
forms.DateInput(
|
||||
attrs={
|
||||
"type": "date",
|
||||
"class": (
|
||||
"px-3 py-2 border border-gray-300 "
|
||||
"dark:border-gray-600 rounded-md focus:ring-2 "
|
||||
"focus:ring-blue-500 focus:border-transparent "
|
||||
"bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
]
|
||||
super().__init__(widgets, attrs)
|
||||
|
||||
def decompress(self, value):
|
||||
if value:
|
||||
return [value.get("start"), value.get("end")]
|
||||
return [None, None]
|
||||
|
||||
|
||||
class DateRangeField(forms.MultiValueField):
|
||||
"""Custom field for date ranges."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
fields = [forms.DateField(required=False), forms.DateField(required=False)]
|
||||
super().__init__(fields, *args, **kwargs)
|
||||
self.widget = DateRangeWidget()
|
||||
|
||||
def compress(self, data_list):
|
||||
if data_list:
|
||||
start_date, end_date = data_list
|
||||
if start_date or end_date:
|
||||
return {"start": start_date, "end": end_date}
|
||||
return None
|
||||
|
||||
def validate(self, value):
|
||||
super().validate(value)
|
||||
if value and value.get("start") and value.get("end"):
|
||||
if value["start"] > value["end"]:
|
||||
raise ValidationError("Start date must be before end date.")
|
||||
|
||||
|
||||
class DateFiltersForm(BaseFilterForm):
|
||||
"""Form for date range filters."""
|
||||
|
||||
opening_date_range = DateRangeField(required=False, label="Opening Date Range")
|
||||
|
||||
closing_date_range = DateRangeField(required=False, label="Closing Date Range")
|
||||
|
||||
status_since_range = DateRangeField(required=False, label="Status Since Range")
|
||||
|
||||
|
||||
class NumberRangeWidget(forms.MultiWidget):
|
||||
"""Custom widget for number range inputs."""
|
||||
|
||||
def __init__(self, attrs=None, min_val=None, max_val=None, step=None):
|
||||
widgets = [
|
||||
forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"px-3 py-2 border border-gray-300 dark:border-gray-600 "
|
||||
"rounded-md focus:ring-2 focus:ring-blue-500 "
|
||||
"focus:border-transparent bg-white dark:bg-gray-800 "
|
||||
"text-gray-900 dark:text-white"
|
||||
),
|
||||
"placeholder": "Min",
|
||||
"min": min_val,
|
||||
"max": max_val,
|
||||
"step": step,
|
||||
}
|
||||
),
|
||||
forms.NumberInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"px-3 py-2 border border-gray-300 dark:border-gray-600 "
|
||||
"rounded-md focus:ring-2 focus:ring-blue-500 "
|
||||
"focus:border-transparent bg-white dark:bg-gray-800 "
|
||||
"text-gray-900 dark:text-white"
|
||||
),
|
||||
"placeholder": "Max",
|
||||
"min": min_val,
|
||||
"max": max_val,
|
||||
"step": step,
|
||||
}
|
||||
),
|
||||
]
|
||||
super().__init__(widgets, attrs)
|
||||
|
||||
def decompress(self, value):
|
||||
if value:
|
||||
return [value.get("min"), value.get("max")]
|
||||
return [None, None]
|
||||
|
||||
|
||||
class NumberRangeField(forms.MultiValueField):
|
||||
"""Custom field for number ranges."""
|
||||
|
||||
def __init__(self, min_val=None, max_val=None, step=None, *args, **kwargs):
|
||||
fields = [
|
||||
forms.FloatField(required=False, min_value=min_val, max_value=max_val),
|
||||
forms.FloatField(required=False, min_value=min_val, max_value=max_val),
|
||||
]
|
||||
super().__init__(fields, *args, **kwargs)
|
||||
self.widget = NumberRangeWidget(min_val=min_val, max_val=max_val, step=step)
|
||||
|
||||
def compress(self, data_list):
|
||||
if data_list:
|
||||
min_val, max_val = data_list
|
||||
if min_val is not None or max_val is not None:
|
||||
return {"min": min_val, "max": max_val}
|
||||
return None
|
||||
|
||||
def validate(self, value):
|
||||
super().validate(value)
|
||||
if value and value.get("min") is not None and value.get("max") is not None:
|
||||
if value["min"] > value["max"]:
|
||||
raise ValidationError("Minimum value must be less than maximum value.")
|
||||
|
||||
|
||||
class HeightSafetyForm(BaseFilterForm):
|
||||
"""Form for height and safety requirement filters."""
|
||||
|
||||
min_height_range = NumberRangeField(
|
||||
min_val=0,
|
||||
max_val=84, # 7 feet in inches
|
||||
step=1,
|
||||
required=False,
|
||||
label="Minimum Height (inches)",
|
||||
)
|
||||
|
||||
max_height_range = NumberRangeField(
|
||||
min_val=0, max_val=84, step=1, required=False, label="Maximum Height (inches)"
|
||||
)
|
||||
|
||||
|
||||
class PerformanceForm(BaseFilterForm):
|
||||
"""Form for performance metric filters."""
|
||||
|
||||
capacity_range = NumberRangeField(
|
||||
min_val=0, max_val=5000, step=50, required=False, label="Capacity per Hour"
|
||||
)
|
||||
|
||||
duration_range = NumberRangeField(
|
||||
min_val=0,
|
||||
max_val=1800, # 30 minutes in seconds
|
||||
step=10,
|
||||
required=False,
|
||||
label="Duration (seconds)",
|
||||
)
|
||||
|
||||
rating_range = NumberRangeField(
|
||||
min_val=0.0, max_val=10.0, step=0.1, required=False, label="Average Rating"
|
||||
)
|
||||
|
||||
|
||||
class RelationshipsForm(BaseFilterForm):
|
||||
"""Form for relationship filters (manufacturer, designer, ride model)."""
|
||||
|
||||
manufacturer = forms.ModelMultipleChoiceField(
|
||||
queryset=Company.objects.filter(roles__contains=["MANUFACTURER"]),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
|
||||
),
|
||||
)
|
||||
|
||||
designer = forms.ModelMultipleChoiceField(
|
||||
queryset=Company.objects.filter(roles__contains=["DESIGNER"]),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
|
||||
),
|
||||
)
|
||||
|
||||
ride_model = forms.ModelMultipleChoiceField(
|
||||
queryset=RideModel.objects.all(),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "max-h-48 overflow-y-auto space-y-1"}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RollerCoasterForm(BaseFilterForm):
|
||||
"""Form for roller coaster specific filters."""
|
||||
|
||||
TRACK_MATERIAL_CHOICES = [
|
||||
("steel", "Steel"),
|
||||
("wood", "Wood"),
|
||||
("hybrid", "Hybrid"),
|
||||
]
|
||||
|
||||
COASTER_TYPE_CHOICES = [
|
||||
("sit_down", "Sit Down"),
|
||||
("inverted", "Inverted"),
|
||||
("floorless", "Floorless"),
|
||||
("flying", "Flying"),
|
||||
("suspended", "Suspended"),
|
||||
("stand_up", "Stand Up"),
|
||||
("spinning", "Spinning"),
|
||||
("launched", "Launched"),
|
||||
("hypercoaster", "Hypercoaster"),
|
||||
("giga_coaster", "Giga Coaster"),
|
||||
("strata_coaster", "Strata Coaster"),
|
||||
]
|
||||
|
||||
LAUNCH_TYPE_CHOICES = [
|
||||
("none", "No Launch"),
|
||||
("lim", "LIM (Linear Induction Motor)"),
|
||||
("lsm", "LSM (Linear Synchronous Motor)"),
|
||||
("hydraulic", "Hydraulic"),
|
||||
("pneumatic", "Pneumatic"),
|
||||
("cable", "Cable"),
|
||||
("flywheel", "Flywheel"),
|
||||
]
|
||||
|
||||
height_ft_range = NumberRangeField(
|
||||
min_val=0, max_val=500, step=1, required=False, label="Height (feet)"
|
||||
)
|
||||
|
||||
length_ft_range = NumberRangeField(
|
||||
min_val=0, max_val=10000, step=10, required=False, label="Length (feet)"
|
||||
)
|
||||
|
||||
speed_mph_range = NumberRangeField(
|
||||
min_val=0, max_val=150, step=1, required=False, label="Speed (mph)"
|
||||
)
|
||||
|
||||
inversions_range = NumberRangeField(
|
||||
min_val=0, max_val=20, step=1, required=False, label="Number of Inversions"
|
||||
)
|
||||
|
||||
track_material = forms.MultipleChoiceField(
|
||||
choices=TRACK_MATERIAL_CHOICES,
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
|
||||
coaster_type = forms.MultipleChoiceField(
|
||||
choices=COASTER_TYPE_CHOICES,
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "grid grid-cols-2 gap-2 max-h-48 overflow-y-auto"}
|
||||
),
|
||||
)
|
||||
|
||||
launch_type = forms.MultipleChoiceField(
|
||||
choices=LAUNCH_TYPE_CHOICES,
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "space-y-2 max-h-48 overflow-y-auto"}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CompanyForm(BaseFilterForm):
|
||||
"""Form for company-related filters."""
|
||||
|
||||
ROLE_CHOICES = [
|
||||
("MANUFACTURER", "Manufacturer"),
|
||||
("DESIGNER", "Designer"),
|
||||
("OPERATOR", "Operator"),
|
||||
]
|
||||
|
||||
manufacturer_roles = forms.MultipleChoiceField(
|
||||
choices=ROLE_CHOICES,
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
|
||||
designer_roles = forms.MultipleChoiceField(
|
||||
choices=ROLE_CHOICES,
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
|
||||
founded_date_range = DateRangeField(
|
||||
required=False, label="Company Founded Date Range"
|
||||
)
|
||||
|
||||
|
||||
class SortingForm(BaseFilterForm):
|
||||
"""Form for sorting options."""
|
||||
|
||||
SORT_CHOICES = [
|
||||
("relevance", "Relevance"),
|
||||
("name_asc", "Name (A-Z)"),
|
||||
("name_desc", "Name (Z-A)"),
|
||||
("opening_date_asc", "Opening Date (Oldest)"),
|
||||
("opening_date_desc", "Opening Date (Newest)"),
|
||||
("rating_asc", "Rating (Lowest)"),
|
||||
("rating_desc", "Rating (Highest)"),
|
||||
("height_asc", "Height (Shortest)"),
|
||||
("height_desc", "Height (Tallest)"),
|
||||
("speed_asc", "Speed (Slowest)"),
|
||||
("speed_desc", "Speed (Fastest)"),
|
||||
("capacity_asc", "Capacity (Lowest)"),
|
||||
("capacity_desc", "Capacity (Highest)"),
|
||||
]
|
||||
|
||||
sort_by = forms.ChoiceField(
|
||||
choices=SORT_CHOICES,
|
||||
required=False,
|
||||
initial="relevance",
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"px-3 py-2 border border-gray-300 dark:border-gray-600 "
|
||||
"rounded-md focus:ring-2 focus:ring-blue-500 "
|
||||
"focus:border-transparent bg-white dark:bg-gray-800 "
|
||||
"text-gray-900 dark:text-white"
|
||||
),
|
||||
"hx-get": "",
|
||||
"hx-trigger": "change",
|
||||
"hx-target": "#search-results",
|
||||
"hx-indicator": "#search-loading",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MasterFilterForm(BaseFilterForm):
|
||||
"""Master form that combines all filter categories."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize all sub-forms
|
||||
self.search_text_form = SearchTextForm(*args, **kwargs)
|
||||
self.basic_info_form = BasicInfoForm(*args, **kwargs)
|
||||
self.date_filters_form = DateFiltersForm(*args, **kwargs)
|
||||
self.height_safety_form = HeightSafetyForm(*args, **kwargs)
|
||||
self.performance_form = PerformanceForm(*args, **kwargs)
|
||||
self.relationships_form = RelationshipsForm(*args, **kwargs)
|
||||
self.roller_coaster_form = RollerCoasterForm(*args, **kwargs)
|
||||
self.company_form = CompanyForm(*args, **kwargs)
|
||||
self.sorting_form = SortingForm(*args, **kwargs)
|
||||
|
||||
# Add all fields to this form
|
||||
self.fields.update(self.search_text_form.fields)
|
||||
self.fields.update(self.basic_info_form.fields)
|
||||
self.fields.update(self.date_filters_form.fields)
|
||||
self.fields.update(self.height_safety_form.fields)
|
||||
self.fields.update(self.performance_form.fields)
|
||||
self.fields.update(self.relationships_form.fields)
|
||||
self.fields.update(self.roller_coaster_form.fields)
|
||||
self.fields.update(self.company_form.fields)
|
||||
self.fields.update(self.sorting_form.fields)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# Validate all sub-forms
|
||||
for form in [
|
||||
self.search_text_form,
|
||||
self.basic_info_form,
|
||||
self.date_filters_form,
|
||||
self.height_safety_form,
|
||||
self.performance_form,
|
||||
self.relationships_form,
|
||||
self.roller_coaster_form,
|
||||
self.company_form,
|
||||
self.sorting_form,
|
||||
]:
|
||||
if not form.is_valid():
|
||||
for field, errors in form.errors.items():
|
||||
self.add_error(field, errors)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def get_search_filters(self) -> Dict[str, Any]:
|
||||
"""Convert form data to search service filter format."""
|
||||
if not self.is_valid():
|
||||
return {}
|
||||
|
||||
filters = {}
|
||||
|
||||
# Add all cleaned data to filters
|
||||
for field_name, value in self.cleaned_data.items():
|
||||
if value: # Only include non-empty values
|
||||
filters[field_name] = value
|
||||
|
||||
return filters
|
||||
|
||||
def get_active_filters_summary(self) -> Dict[str, Any]:
|
||||
"""Get summary of active filters for display."""
|
||||
active_filters = {}
|
||||
|
||||
if not self.is_valid():
|
||||
return active_filters
|
||||
|
||||
# Group filters by category
|
||||
categories = {
|
||||
"Search": ["global_search", "name_search", "description_search"],
|
||||
"Basic Info": ["category", "status", "park", "park_area"],
|
||||
"Dates": ["opening_date_range", "closing_date_range", "status_since_range"],
|
||||
"Height & Safety": ["min_height_range", "max_height_range"],
|
||||
"Performance": ["capacity_range", "duration_range", "rating_range"],
|
||||
"Relationships": ["manufacturer", "designer", "ride_model"],
|
||||
"Roller Coaster": [
|
||||
"height_ft_range",
|
||||
"length_ft_range",
|
||||
"speed_mph_range",
|
||||
"inversions_range",
|
||||
"track_material",
|
||||
"coaster_type",
|
||||
"launch_type",
|
||||
],
|
||||
"Company": ["manufacturer_roles", "designer_roles", "founded_date_range"],
|
||||
}
|
||||
|
||||
for category, field_names in categories.items():
|
||||
category_filters = []
|
||||
for field_name in field_names:
|
||||
value = self.cleaned_data.get(field_name)
|
||||
if value:
|
||||
category_filters.append(
|
||||
{
|
||||
"field": field_name,
|
||||
"value": value,
|
||||
"label": self.fields[field_name].label
|
||||
or field_name.replace("_", " ").title(),
|
||||
}
|
||||
)
|
||||
|
||||
if category_filters:
|
||||
active_filters[category] = category_filters
|
||||
|
||||
return active_filters
|
||||
198
backend/apps/rides/serializers.py
Normal file
198
backend/apps/rides/serializers.py
Normal file
@@ -0,0 +1,198 @@
|
||||
from rest_framework import serializers
|
||||
from .models.rides import Ride, RideModel, Categories
|
||||
from .models.company import Company
|
||||
from apps.parks.models import Park
|
||||
|
||||
|
||||
class CompanySerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Company model"""
|
||||
|
||||
class Meta:
|
||||
model = Company
|
||||
fields = ["id", "name", "roles"]
|
||||
|
||||
|
||||
class RideModelSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for RideModel"""
|
||||
|
||||
manufacturer = CompanySerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RideModel
|
||||
fields = ["id", "name", "manufacturer"]
|
||||
|
||||
|
||||
class ParkSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Park model"""
|
||||
|
||||
location_city = serializers.CharField(source="location.city", read_only=True)
|
||||
location_country = serializers.CharField(source="location.country", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Park
|
||||
fields = ["id", "name", "slug", "location_city", "location_country"]
|
||||
|
||||
|
||||
class RideSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Ride model with all necessary fields for filtering display"""
|
||||
|
||||
park = ParkSerializer(read_only=True)
|
||||
ride_model = RideModelSerializer(read_only=True)
|
||||
manufacturer = serializers.SerializerMethodField()
|
||||
designer = CompanySerializer(read_only=True)
|
||||
category_display = serializers.SerializerMethodField()
|
||||
status_display = serializers.SerializerMethodField()
|
||||
|
||||
# Photo fields
|
||||
primary_photo_url = serializers.SerializerMethodField()
|
||||
photo_count = serializers.SerializerMethodField()
|
||||
|
||||
# Computed fields
|
||||
age_years = serializers.SerializerMethodField()
|
||||
is_operating = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Ride
|
||||
fields = [
|
||||
# Basic info
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"description",
|
||||
# Relationships
|
||||
"park",
|
||||
"ride_model",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
# Categories and status
|
||||
"category",
|
||||
"category_display",
|
||||
"status",
|
||||
"status_display",
|
||||
# Dates
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"age_years",
|
||||
"is_operating",
|
||||
# Physical characteristics
|
||||
"height_ft",
|
||||
"max_speed_mph",
|
||||
"duration_seconds",
|
||||
"hourly_capacity",
|
||||
"length_ft",
|
||||
"inversions",
|
||||
"max_g_force",
|
||||
"max_angle_degrees",
|
||||
# Features (coaster specific)
|
||||
"lift_mechanism",
|
||||
"restraint_type",
|
||||
"track_material",
|
||||
# Media
|
||||
"primary_photo_url",
|
||||
"photo_count",
|
||||
# Metadata
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def get_manufacturer(self, obj):
|
||||
"""Get manufacturer from ride model if available"""
|
||||
if obj.ride_model and obj.ride_model.manufacturer:
|
||||
return CompanySerializer(obj.ride_model.manufacturer).data
|
||||
return None
|
||||
|
||||
def get_category_display(self, obj):
|
||||
"""Get human-readable category name"""
|
||||
return dict(Categories).get(obj.category, obj.category)
|
||||
|
||||
def get_status_display(self, obj):
|
||||
"""Get human-readable status"""
|
||||
return obj.get_status_display()
|
||||
|
||||
def get_primary_photo_url(self, obj):
|
||||
"""Get URL of primary photo if available"""
|
||||
# This would need to be implemented based on your photo model
|
||||
# For now, return None
|
||||
return None
|
||||
|
||||
def get_photo_count(self, obj):
|
||||
"""Get number of photos for this ride"""
|
||||
# This would need to be implemented based on your photo model
|
||||
# For now, return 0
|
||||
return 0
|
||||
|
||||
def get_age_years(self, obj):
|
||||
"""Calculate ride age in years"""
|
||||
if obj.opening_date:
|
||||
from datetime import date
|
||||
|
||||
today = date.today()
|
||||
return today.year - obj.opening_date.year
|
||||
return None
|
||||
|
||||
def get_is_operating(self, obj):
|
||||
"""Check if ride is currently operating"""
|
||||
return obj.status == "OPERATING"
|
||||
|
||||
|
||||
class RideListSerializer(RideSerializer):
|
||||
"""Lightweight serializer for ride lists"""
|
||||
|
||||
class Meta(RideSerializer.Meta):
|
||||
fields = [
|
||||
# Essential fields for list view
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"park",
|
||||
"category",
|
||||
"category_display",
|
||||
"status",
|
||||
"status_display",
|
||||
"opening_date",
|
||||
"age_years",
|
||||
"is_operating",
|
||||
"height_ft",
|
||||
"max_speed_mph",
|
||||
"manufacturer",
|
||||
"primary_photo_url",
|
||||
]
|
||||
|
||||
|
||||
class RideFilterOptionsSerializer(serializers.Serializer):
|
||||
"""Serializer for filter options response"""
|
||||
|
||||
categories = serializers.ListField(
|
||||
child=serializers.DictField(), help_text="Available ride categories"
|
||||
)
|
||||
manufacturers = serializers.ListField(
|
||||
child=serializers.DictField(), help_text="Available manufacturers"
|
||||
)
|
||||
designers = serializers.ListField(
|
||||
child=serializers.DictField(), help_text="Available designers"
|
||||
)
|
||||
parks = serializers.ListField(
|
||||
child=serializers.DictField(), help_text="Available parks (for global view)"
|
||||
)
|
||||
ride_models = serializers.ListField(
|
||||
child=serializers.DictField(), help_text="Available ride models"
|
||||
)
|
||||
ranges = serializers.DictField(help_text="Value ranges for numeric filters")
|
||||
date_ranges = serializers.DictField(help_text="Date ranges for date filters")
|
||||
|
||||
|
||||
class FilterMetadataSerializer(serializers.Serializer):
|
||||
"""Serializer for filter metadata in list responses"""
|
||||
|
||||
active_filters = serializers.DictField(
|
||||
help_text="Summary of currently active filters"
|
||||
)
|
||||
has_filters = serializers.BooleanField(
|
||||
help_text="Whether any filters are currently active"
|
||||
)
|
||||
total_rides = serializers.IntegerField(
|
||||
help_text="Total number of rides before filtering"
|
||||
)
|
||||
filtered_count = serializers.IntegerField(
|
||||
help_text="Number of rides after filtering"
|
||||
)
|
||||
727
backend/apps/rides/services/search.py
Normal file
727
backend/apps/rides/services/search.py
Normal file
@@ -0,0 +1,727 @@
|
||||
"""
|
||||
Ride Search Service
|
||||
|
||||
Provides comprehensive search and filtering capabilities for rides using PostgreSQL's
|
||||
advanced full-text search features including SearchVector, SearchQuery, SearchRank,
|
||||
and TrigramSimilarity for fuzzy matching.
|
||||
|
||||
This service implements the filtering design specified in:
|
||||
backend/docs/ride_filtering_design.md
|
||||
"""
|
||||
|
||||
from django.contrib.postgres.search import (
|
||||
SearchVector,
|
||||
SearchQuery,
|
||||
SearchRank,
|
||||
TrigramSimilarity,
|
||||
)
|
||||
from django.db import models
|
||||
from django.db.models import Q, F, Value
|
||||
from django.db.models.functions import Greatest
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
|
||||
from apps.rides.models import Ride
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models.company import Company
|
||||
|
||||
|
||||
class RideSearchService:
|
||||
"""
|
||||
Advanced search service for rides with PostgreSQL full-text search capabilities.
|
||||
|
||||
Features:
|
||||
- Full-text search with ranking and highlighting
|
||||
- Fuzzy matching with trigram similarity
|
||||
- Comprehensive filtering across 8 categories
|
||||
- Range filtering for numeric fields
|
||||
- Date range filtering
|
||||
- Multi-select filtering
|
||||
- Sorting with multiple options
|
||||
- Search suggestions and autocomplete
|
||||
"""
|
||||
|
||||
# Search configuration
|
||||
SEARCH_LANGUAGES = ["english"]
|
||||
TRIGRAM_SIMILARITY_THRESHOLD = 0.3
|
||||
SEARCH_RANK_WEIGHTS = [0.1, 0.2, 0.4, 1.0] # D, C, B, A weights
|
||||
|
||||
# Filter categories from our design
|
||||
FILTER_CATEGORIES = {
|
||||
"search_text": ["global_search", "name_search", "description_search"],
|
||||
"basic_info": ["category", "status", "park", "park_area"],
|
||||
"dates": ["opening_date_range", "closing_date_range", "status_since_range"],
|
||||
"height_safety": ["min_height_range", "max_height_range"],
|
||||
"performance": ["capacity_range", "duration_range", "rating_range"],
|
||||
"relationships": ["manufacturer", "designer", "ride_model"],
|
||||
"roller_coaster": [
|
||||
"height_ft_range",
|
||||
"length_ft_range",
|
||||
"speed_mph_range",
|
||||
"inversions_range",
|
||||
"track_material",
|
||||
"coaster_type",
|
||||
"launch_type",
|
||||
],
|
||||
"company": ["manufacturer_roles", "designer_roles", "founded_date_range"],
|
||||
}
|
||||
|
||||
# Sorting options
|
||||
SORT_OPTIONS = {
|
||||
"relevance": "search_rank",
|
||||
"name_asc": "name",
|
||||
"name_desc": "-name",
|
||||
"opening_date_asc": "opening_date",
|
||||
"opening_date_desc": "-opening_date",
|
||||
"rating_asc": "average_rating",
|
||||
"rating_desc": "-average_rating",
|
||||
"height_asc": "rollercoasterstats__height_ft",
|
||||
"height_desc": "-rollercoasterstats__height_ft",
|
||||
"speed_asc": "rollercoasterstats__speed_mph",
|
||||
"speed_desc": "-rollercoasterstats__speed_mph",
|
||||
"capacity_asc": "capacity_per_hour",
|
||||
"capacity_desc": "-capacity_per_hour",
|
||||
"created_asc": "created_at",
|
||||
"created_desc": "-created_at",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the search service."""
|
||||
self.base_queryset = self._get_base_queryset()
|
||||
|
||||
def _get_base_queryset(self):
|
||||
"""
|
||||
Get the base queryset with all necessary relationships pre-loaded
|
||||
for optimal performance.
|
||||
"""
|
||||
return Ride.objects.select_related(
|
||||
"park",
|
||||
"park_area",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"ride_model",
|
||||
"rollercoasterstats",
|
||||
).prefetch_related("manufacturer__roles", "designer__roles")
|
||||
|
||||
def search_and_filter(
|
||||
self,
|
||||
filters: Dict[str, Any],
|
||||
sort_by: str = "relevance",
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Main search and filter method that combines all capabilities.
|
||||
|
||||
Args:
|
||||
filters: Dictionary of filter parameters
|
||||
sort_by: Sorting option key
|
||||
page: Page number for pagination
|
||||
page_size: Number of results per page
|
||||
|
||||
Returns:
|
||||
Dictionary containing results, pagination info, and metadata
|
||||
"""
|
||||
queryset = self.base_queryset
|
||||
search_metadata = {}
|
||||
|
||||
# Apply text search with ranking
|
||||
if filters.get("global_search"):
|
||||
queryset, search_rank = self._apply_full_text_search(
|
||||
queryset, filters["global_search"]
|
||||
)
|
||||
search_metadata["search_applied"] = True
|
||||
search_metadata["search_term"] = filters["global_search"]
|
||||
else:
|
||||
search_rank = Value(0)
|
||||
|
||||
# Apply all filter categories
|
||||
queryset = self._apply_basic_info_filters(queryset, filters)
|
||||
queryset = self._apply_date_filters(queryset, filters)
|
||||
queryset = self._apply_height_safety_filters(queryset, filters)
|
||||
queryset = self._apply_performance_filters(queryset, filters)
|
||||
queryset = self._apply_relationship_filters(queryset, filters)
|
||||
queryset = self._apply_roller_coaster_filters(queryset, filters)
|
||||
queryset = self._apply_company_filters(queryset, filters)
|
||||
|
||||
# Add search rank to queryset for sorting
|
||||
queryset = queryset.annotate(search_rank=search_rank)
|
||||
|
||||
# Apply sorting
|
||||
queryset = self._apply_sorting(queryset, sort_by)
|
||||
|
||||
# Get total count before pagination
|
||||
total_count = queryset.count()
|
||||
|
||||
# Apply pagination
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
results = list(queryset[start_idx:end_idx])
|
||||
|
||||
# Generate search highlights if search was applied
|
||||
if filters.get("global_search"):
|
||||
results = self._add_search_highlights(results, filters["global_search"])
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_count": total_count,
|
||||
"total_pages": (total_count + page_size - 1) // page_size,
|
||||
"has_next": end_idx < total_count,
|
||||
"has_previous": page > 1,
|
||||
},
|
||||
"metadata": search_metadata,
|
||||
"applied_filters": self._get_applied_filters_summary(filters),
|
||||
}
|
||||
|
||||
def _apply_full_text_search(
|
||||
self, queryset, search_term: str
|
||||
) -> Tuple[models.QuerySet, models.Expression]:
|
||||
"""
|
||||
Apply PostgreSQL full-text search with ranking and fuzzy matching.
|
||||
"""
|
||||
if not search_term or not search_term.strip():
|
||||
return queryset, Value(0)
|
||||
|
||||
search_term = search_term.strip()
|
||||
|
||||
# Create search vector combining multiple fields with different weights
|
||||
search_vector = (
|
||||
SearchVector("name", weight="A")
|
||||
+ SearchVector("description", weight="B")
|
||||
+ SearchVector("park__name", weight="C")
|
||||
+ SearchVector("manufacturer__name", weight="C")
|
||||
+ SearchVector("designer__name", weight="C")
|
||||
+ SearchVector("ride_model__name", weight="D")
|
||||
)
|
||||
|
||||
# Create search query - try different query types for best results
|
||||
search_query = SearchQuery(search_term, config="english")
|
||||
|
||||
# Calculate search rank
|
||||
search_rank = SearchRank(
|
||||
search_vector, search_query, weights=self.SEARCH_RANK_WEIGHTS
|
||||
)
|
||||
|
||||
# Apply trigram similarity for fuzzy matching on name
|
||||
trigram_similarity = TrigramSimilarity("name", search_term)
|
||||
|
||||
# Combine full-text search with trigram similarity
|
||||
queryset = queryset.annotate(trigram_similarity=trigram_similarity).filter(
|
||||
Q(search_vector=search_query)
|
||||
| Q(trigram_similarity__gte=self.TRIGRAM_SIMILARITY_THRESHOLD)
|
||||
)
|
||||
|
||||
# Use the greatest of search rank and trigram similarity for final ranking
|
||||
final_rank = Greatest(search_rank, F("trigram_similarity"))
|
||||
|
||||
return queryset, final_rank
|
||||
|
||||
def _apply_basic_info_filters(
|
||||
self, queryset, filters: Dict[str, Any]
|
||||
) -> models.QuerySet:
|
||||
"""Apply basic information filters."""
|
||||
|
||||
# Category filter (multi-select)
|
||||
if filters.get("category"):
|
||||
categories = (
|
||||
filters["category"]
|
||||
if isinstance(filters["category"], list)
|
||||
else [filters["category"]]
|
||||
)
|
||||
queryset = queryset.filter(category__in=categories)
|
||||
|
||||
# Status filter (multi-select)
|
||||
if filters.get("status"):
|
||||
statuses = (
|
||||
filters["status"]
|
||||
if isinstance(filters["status"], list)
|
||||
else [filters["status"]]
|
||||
)
|
||||
queryset = queryset.filter(status__in=statuses)
|
||||
|
||||
# Park filter (multi-select)
|
||||
if filters.get("park"):
|
||||
parks = (
|
||||
filters["park"]
|
||||
if isinstance(filters["park"], list)
|
||||
else [filters["park"]]
|
||||
)
|
||||
if isinstance(parks[0], str): # If slugs provided
|
||||
queryset = queryset.filter(park__slug__in=parks)
|
||||
else: # If IDs provided
|
||||
queryset = queryset.filter(park_id__in=parks)
|
||||
|
||||
# Park area filter (multi-select)
|
||||
if filters.get("park_area"):
|
||||
areas = (
|
||||
filters["park_area"]
|
||||
if isinstance(filters["park_area"], list)
|
||||
else [filters["park_area"]]
|
||||
)
|
||||
if isinstance(areas[0], str): # If slugs provided
|
||||
queryset = queryset.filter(park_area__slug__in=areas)
|
||||
else: # If IDs provided
|
||||
queryset = queryset.filter(park_area_id__in=areas)
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_date_filters(self, queryset, filters: Dict[str, Any]) -> models.QuerySet:
|
||||
"""Apply date range filters."""
|
||||
|
||||
# Opening date range
|
||||
if filters.get("opening_date_range"):
|
||||
date_range = filters["opening_date_range"]
|
||||
if date_range.get("start"):
|
||||
queryset = queryset.filter(opening_date__gte=date_range["start"])
|
||||
if date_range.get("end"):
|
||||
queryset = queryset.filter(opening_date__lte=date_range["end"])
|
||||
|
||||
# Closing date range
|
||||
if filters.get("closing_date_range"):
|
||||
date_range = filters["closing_date_range"]
|
||||
if date_range.get("start"):
|
||||
queryset = queryset.filter(closing_date__gte=date_range["start"])
|
||||
if date_range.get("end"):
|
||||
queryset = queryset.filter(closing_date__lte=date_range["end"])
|
||||
|
||||
# Status since range
|
||||
if filters.get("status_since_range"):
|
||||
date_range = filters["status_since_range"]
|
||||
if date_range.get("start"):
|
||||
queryset = queryset.filter(status_since__gte=date_range["start"])
|
||||
if date_range.get("end"):
|
||||
queryset = queryset.filter(status_since__lte=date_range["end"])
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_height_safety_filters(
|
||||
self, queryset, filters: Dict[str, Any]
|
||||
) -> models.QuerySet:
|
||||
"""Apply height and safety requirement filters."""
|
||||
|
||||
# Minimum height range
|
||||
if filters.get("min_height_range"):
|
||||
height_range = filters["min_height_range"]
|
||||
if height_range.get("min") is not None:
|
||||
queryset = queryset.filter(min_height_in__gte=height_range["min"])
|
||||
if height_range.get("max") is not None:
|
||||
queryset = queryset.filter(min_height_in__lte=height_range["max"])
|
||||
|
||||
# Maximum height range
|
||||
if filters.get("max_height_range"):
|
||||
height_range = filters["max_height_range"]
|
||||
if height_range.get("min") is not None:
|
||||
queryset = queryset.filter(max_height_in__gte=height_range["min"])
|
||||
if height_range.get("max") is not None:
|
||||
queryset = queryset.filter(max_height_in__lte=height_range["max"])
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_performance_filters(
|
||||
self, queryset, filters: Dict[str, Any]
|
||||
) -> models.QuerySet:
|
||||
"""Apply performance metric filters."""
|
||||
|
||||
# Capacity range
|
||||
if filters.get("capacity_range"):
|
||||
capacity_range = filters["capacity_range"]
|
||||
if capacity_range.get("min") is not None:
|
||||
queryset = queryset.filter(capacity_per_hour__gte=capacity_range["min"])
|
||||
if capacity_range.get("max") is not None:
|
||||
queryset = queryset.filter(capacity_per_hour__lte=capacity_range["max"])
|
||||
|
||||
# Duration range
|
||||
if filters.get("duration_range"):
|
||||
duration_range = filters["duration_range"]
|
||||
if duration_range.get("min") is not None:
|
||||
queryset = queryset.filter(
|
||||
ride_duration_seconds__gte=duration_range["min"]
|
||||
)
|
||||
if duration_range.get("max") is not None:
|
||||
queryset = queryset.filter(
|
||||
ride_duration_seconds__lte=duration_range["max"]
|
||||
)
|
||||
|
||||
# Rating range
|
||||
if filters.get("rating_range"):
|
||||
rating_range = filters["rating_range"]
|
||||
if rating_range.get("min") is not None:
|
||||
queryset = queryset.filter(average_rating__gte=rating_range["min"])
|
||||
if rating_range.get("max") is not None:
|
||||
queryset = queryset.filter(average_rating__lte=rating_range["max"])
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_relationship_filters(
|
||||
self, queryset, filters: Dict[str, Any]
|
||||
) -> models.QuerySet:
|
||||
"""Apply relationship filters (manufacturer, designer, ride model)."""
|
||||
|
||||
# Manufacturer filter (multi-select)
|
||||
if filters.get("manufacturer"):
|
||||
manufacturers = (
|
||||
filters["manufacturer"]
|
||||
if isinstance(filters["manufacturer"], list)
|
||||
else [filters["manufacturer"]]
|
||||
)
|
||||
if isinstance(manufacturers[0], str): # If slugs provided
|
||||
queryset = queryset.filter(manufacturer__slug__in=manufacturers)
|
||||
else: # If IDs provided
|
||||
queryset = queryset.filter(manufacturer_id__in=manufacturers)
|
||||
|
||||
# Designer filter (multi-select)
|
||||
if filters.get("designer"):
|
||||
designers = (
|
||||
filters["designer"]
|
||||
if isinstance(filters["designer"], list)
|
||||
else [filters["designer"]]
|
||||
)
|
||||
if isinstance(designers[0], str): # If slugs provided
|
||||
queryset = queryset.filter(designer__slug__in=designers)
|
||||
else: # If IDs provided
|
||||
queryset = queryset.filter(designer_id__in=designers)
|
||||
|
||||
# Ride model filter (multi-select)
|
||||
if filters.get("ride_model"):
|
||||
models_list = (
|
||||
filters["ride_model"]
|
||||
if isinstance(filters["ride_model"], list)
|
||||
else [filters["ride_model"]]
|
||||
)
|
||||
if isinstance(models_list[0], str): # If slugs provided
|
||||
queryset = queryset.filter(ride_model__slug__in=models_list)
|
||||
else: # If IDs provided
|
||||
queryset = queryset.filter(ride_model_id__in=models_list)
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_roller_coaster_filters(
|
||||
self, queryset, filters: Dict[str, Any]
|
||||
) -> models.QuerySet:
|
||||
"""Apply roller coaster specific filters."""
|
||||
queryset = self._apply_numeric_range_filter(
|
||||
queryset, filters, "height_ft_range", "rollercoasterstats__height_ft"
|
||||
)
|
||||
queryset = self._apply_numeric_range_filter(
|
||||
queryset, filters, "length_ft_range", "rollercoasterstats__length_ft"
|
||||
)
|
||||
queryset = self._apply_numeric_range_filter(
|
||||
queryset, filters, "speed_mph_range", "rollercoasterstats__speed_mph"
|
||||
)
|
||||
queryset = self._apply_numeric_range_filter(
|
||||
queryset, filters, "inversions_range", "rollercoasterstats__inversions"
|
||||
)
|
||||
|
||||
# Track material filter (multi-select)
|
||||
if filters.get("track_material"):
|
||||
materials = (
|
||||
filters["track_material"]
|
||||
if isinstance(filters["track_material"], list)
|
||||
else [filters["track_material"]]
|
||||
)
|
||||
queryset = queryset.filter(rollercoasterstats__track_material__in=materials)
|
||||
|
||||
# Coaster type filter (multi-select)
|
||||
if filters.get("coaster_type"):
|
||||
types = (
|
||||
filters["coaster_type"]
|
||||
if isinstance(filters["coaster_type"], list)
|
||||
else [filters["coaster_type"]]
|
||||
)
|
||||
queryset = queryset.filter(
|
||||
rollercoasterstats__roller_coaster_type__in=types
|
||||
)
|
||||
|
||||
# Launch type filter (multi-select)
|
||||
if filters.get("launch_type"):
|
||||
launch_types = (
|
||||
filters["launch_type"]
|
||||
if isinstance(filters["launch_type"], list)
|
||||
else [filters["launch_type"]]
|
||||
)
|
||||
queryset = queryset.filter(rollercoasterstats__launch_type__in=launch_types)
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_numeric_range_filter(
|
||||
self,
|
||||
queryset,
|
||||
filters: Dict[str, Any],
|
||||
filter_key: str,
|
||||
field_name: str,
|
||||
) -> models.QuerySet:
|
||||
"""Apply numeric range filter to reduce complexity."""
|
||||
if filters.get(filter_key):
|
||||
range_filter = filters[filter_key]
|
||||
if range_filter.get("min") is not None:
|
||||
queryset = queryset.filter(
|
||||
**{f"{field_name}__gte": range_filter["min"]}
|
||||
)
|
||||
if range_filter.get("max") is not None:
|
||||
queryset = queryset.filter(
|
||||
**{f"{field_name}__lte": range_filter["max"]}
|
||||
)
|
||||
return queryset
|
||||
|
||||
def _apply_company_filters(
|
||||
self, queryset, filters: Dict[str, Any]
|
||||
) -> models.QuerySet:
|
||||
"""Apply company-related filters."""
|
||||
|
||||
# Manufacturer roles filter
|
||||
if filters.get("manufacturer_roles"):
|
||||
roles = (
|
||||
filters["manufacturer_roles"]
|
||||
if isinstance(filters["manufacturer_roles"], list)
|
||||
else [filters["manufacturer_roles"]]
|
||||
)
|
||||
queryset = queryset.filter(manufacturer__roles__overlap=roles)
|
||||
|
||||
# Designer roles filter
|
||||
if filters.get("designer_roles"):
|
||||
roles = (
|
||||
filters["designer_roles"]
|
||||
if isinstance(filters["designer_roles"], list)
|
||||
else [filters["designer_roles"]]
|
||||
)
|
||||
queryset = queryset.filter(designer__roles__overlap=roles)
|
||||
|
||||
# Founded date range
|
||||
if filters.get("founded_date_range"):
|
||||
date_range = filters["founded_date_range"]
|
||||
if date_range.get("start"):
|
||||
queryset = queryset.filter(
|
||||
Q(manufacturer__founded_date__gte=date_range["start"])
|
||||
| Q(designer__founded_date__gte=date_range["start"])
|
||||
)
|
||||
if date_range.get("end"):
|
||||
queryset = queryset.filter(
|
||||
Q(manufacturer__founded_date__lte=date_range["end"])
|
||||
| Q(designer__founded_date__lte=date_range["end"])
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_sorting(self, queryset, sort_by: str) -> models.QuerySet:
|
||||
"""Apply sorting to the queryset."""
|
||||
|
||||
if sort_by not in self.SORT_OPTIONS:
|
||||
sort_by = "relevance"
|
||||
|
||||
sort_field = self.SORT_OPTIONS[sort_by]
|
||||
|
||||
# Handle special case for relevance sorting
|
||||
if sort_by == "relevance":
|
||||
return queryset.order_by("-search_rank", "name")
|
||||
|
||||
# Apply the sorting
|
||||
return queryset.order_by(
|
||||
sort_field, "name"
|
||||
) # Always add name as secondary sort
|
||||
|
||||
def _add_search_highlights(
|
||||
self, results: List[Ride], search_term: str
|
||||
) -> List[Ride]:
|
||||
"""Add search highlights to results using SearchHeadline."""
|
||||
|
||||
if not search_term or not results:
|
||||
return results
|
||||
|
||||
# Create search query for highlighting
|
||||
SearchQuery(search_term, config="english")
|
||||
|
||||
# Add highlights to each result
|
||||
# (note: highlights would need to be processed at query time)
|
||||
for ride in results:
|
||||
# Store highlighted versions as dynamic attributes (for template use)
|
||||
setattr(ride, "highlighted_name", ride.name)
|
||||
setattr(ride, "highlighted_description", ride.description)
|
||||
|
||||
return results
|
||||
|
||||
def _get_applied_filters_summary(self, filters: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Generate a summary of applied filters for the frontend."""
|
||||
|
||||
applied = {}
|
||||
|
||||
# Count filters in each category
|
||||
for category, filter_keys in self.FILTER_CATEGORIES.items():
|
||||
category_filters = []
|
||||
for key in filter_keys:
|
||||
if filters.get(key):
|
||||
category_filters.append(
|
||||
{
|
||||
"key": key,
|
||||
"value": filters[key],
|
||||
"display_name": self._get_filter_display_name(key),
|
||||
}
|
||||
)
|
||||
if category_filters:
|
||||
applied[category] = category_filters
|
||||
|
||||
return applied
|
||||
|
||||
def _get_filter_display_name(self, filter_key: str) -> str:
|
||||
"""Convert filter key to human-readable display name."""
|
||||
|
||||
display_names = {
|
||||
"global_search": "Search",
|
||||
"category": "Category",
|
||||
"status": "Status",
|
||||
"park": "Park",
|
||||
"park_area": "Park Area",
|
||||
"opening_date_range": "Opening Date",
|
||||
"closing_date_range": "Closing Date",
|
||||
"status_since_range": "Status Since",
|
||||
"min_height_range": "Minimum Height",
|
||||
"max_height_range": "Maximum Height",
|
||||
"capacity_range": "Capacity",
|
||||
"duration_range": "Duration",
|
||||
"rating_range": "Rating",
|
||||
"manufacturer": "Manufacturer",
|
||||
"designer": "Designer",
|
||||
"ride_model": "Ride Model",
|
||||
"height_ft_range": "Height (ft)",
|
||||
"length_ft_range": "Length (ft)",
|
||||
"speed_mph_range": "Speed (mph)",
|
||||
"inversions_range": "Inversions",
|
||||
"track_material": "Track Material",
|
||||
"coaster_type": "Coaster Type",
|
||||
"launch_type": "Launch Type",
|
||||
"manufacturer_roles": "Manufacturer Roles",
|
||||
"designer_roles": "Designer Roles",
|
||||
"founded_date_range": "Founded Date",
|
||||
}
|
||||
|
||||
return display_names.get(filter_key, filter_key.replace("_", " ").title())
|
||||
|
||||
def get_search_suggestions(
|
||||
self, query: str, limit: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get search suggestions for autocomplete functionality.
|
||||
"""
|
||||
|
||||
if not query or len(query) < 2:
|
||||
return []
|
||||
|
||||
suggestions = []
|
||||
|
||||
# Ride names with trigram similarity
|
||||
ride_suggestions = (
|
||||
Ride.objects.annotate(similarity=TrigramSimilarity("name", query))
|
||||
.filter(similarity__gte=0.1)
|
||||
.order_by("-similarity")
|
||||
.values("name", "slug", "similarity")[: limit // 2]
|
||||
)
|
||||
|
||||
for ride in ride_suggestions:
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "ride",
|
||||
"text": ride["name"],
|
||||
"slug": ride["slug"],
|
||||
"score": ride["similarity"],
|
||||
}
|
||||
)
|
||||
|
||||
# Park names
|
||||
park_suggestions = (
|
||||
Park.objects.annotate(similarity=TrigramSimilarity("name", query))
|
||||
.filter(similarity__gte=0.1)
|
||||
.order_by("-similarity")
|
||||
.values("name", "slug", "similarity")[: limit // 4]
|
||||
)
|
||||
|
||||
for park in park_suggestions:
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "park",
|
||||
"text": park["name"],
|
||||
"slug": park["slug"],
|
||||
"score": park["similarity"],
|
||||
}
|
||||
)
|
||||
|
||||
# Manufacturer names
|
||||
manufacturer_suggestions = (
|
||||
Company.objects.filter(roles__contains=["MANUFACTURER"])
|
||||
.annotate(similarity=TrigramSimilarity("name", query))
|
||||
.filter(similarity__gte=0.1)
|
||||
.order_by("-similarity")
|
||||
.values("name", "slug", "similarity")[: limit // 4]
|
||||
)
|
||||
|
||||
for manufacturer in manufacturer_suggestions:
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "manufacturer",
|
||||
"text": manufacturer["name"],
|
||||
"slug": manufacturer["slug"],
|
||||
"score": manufacturer["similarity"],
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by score and return top results
|
||||
suggestions.sort(key=lambda x: x["score"], reverse=True)
|
||||
return suggestions[:limit]
|
||||
|
||||
def get_filter_options(
|
||||
self, filter_type: str, context_filters: Optional[Dict[str, Any]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get available options for a specific filter type.
|
||||
Optionally filter options based on current context.
|
||||
"""
|
||||
|
||||
context_filters = context_filters or {}
|
||||
base_queryset = self.base_queryset
|
||||
|
||||
# Apply context filters to narrow down options
|
||||
if context_filters:
|
||||
temp_filters = context_filters.copy()
|
||||
temp_filters.pop(
|
||||
filter_type, None
|
||||
) # Remove the filter we're getting options for
|
||||
base_queryset = self._apply_all_filters(base_queryset, temp_filters)
|
||||
|
||||
if filter_type == "park":
|
||||
return list(
|
||||
base_queryset.values("park__name", "park__slug")
|
||||
.distinct()
|
||||
.order_by("park__name")
|
||||
)
|
||||
|
||||
elif filter_type == "manufacturer":
|
||||
return list(
|
||||
base_queryset.filter(manufacturer__isnull=False)
|
||||
.values("manufacturer__name", "manufacturer__slug")
|
||||
.distinct()
|
||||
.order_by("manufacturer__name")
|
||||
)
|
||||
|
||||
elif filter_type == "designer":
|
||||
return list(
|
||||
base_queryset.filter(designer__isnull=False)
|
||||
.values("designer__name", "designer__slug")
|
||||
.distinct()
|
||||
.order_by("designer__name")
|
||||
)
|
||||
|
||||
# Add more filter options as needed
|
||||
return []
|
||||
|
||||
def _apply_all_filters(self, queryset, filters: Dict[str, Any]) -> models.QuerySet:
|
||||
"""Apply all filters except search ranking."""
|
||||
|
||||
queryset = self._apply_basic_info_filters(queryset, filters)
|
||||
queryset = self._apply_date_filters(queryset, filters)
|
||||
queryset = self._apply_height_safety_filters(queryset, filters)
|
||||
queryset = self._apply_performance_filters(queryset, filters)
|
||||
queryset = self._apply_relationship_filters(queryset, filters)
|
||||
queryset = self._apply_roller_coaster_filters(queryset, filters)
|
||||
queryset = self._apply_company_filters(queryset, filters)
|
||||
|
||||
return queryset
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.urls import path
|
||||
from django.urls import path, include
|
||||
from . import views
|
||||
|
||||
app_name = "rides"
|
||||
@@ -70,6 +70,8 @@ urlpatterns = [
|
||||
views.ranking_comparisons,
|
||||
name="ranking_comparisons",
|
||||
),
|
||||
# API endpoints for Vue.js frontend
|
||||
path("api/", include("apps.rides.api_urls", namespace="rides_api")),
|
||||
# Park-specific URLs
|
||||
path("create/", views.RideCreateView.as_view(), name="ride_create"),
|
||||
path("<slug:ride_slug>/", views.RideDetailView.as_view(), name="ride_detail"),
|
||||
|
||||
@@ -9,6 +9,8 @@ from django.db.models import Count
|
||||
from .models.rides import Ride, RideModel, Categories
|
||||
from .models.company import Company
|
||||
from .forms import RideForm, RideSearchForm
|
||||
from .forms.search import MasterFilterForm
|
||||
from .services.search import RideSearchService
|
||||
from apps.parks.models import Park
|
||||
from apps.moderation.mixins import EditSubmissionMixin, HistoryMixin
|
||||
from apps.moderation.models import EditSubmission
|
||||
@@ -213,66 +215,81 @@ class RideUpdateView(
|
||||
|
||||
|
||||
class RideListView(ListView):
|
||||
"""View for displaying a list of rides"""
|
||||
"""Enhanced view for displaying a list of rides with advanced filtering"""
|
||||
|
||||
model = Ride
|
||||
template_name = "rides/ride_list.html"
|
||||
context_object_name = "rides"
|
||||
paginate_by = 24
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get filtered rides based on search and filters"""
|
||||
queryset = (
|
||||
Ride.objects.all()
|
||||
.select_related("park", "ride_model", "ride_model__manufacturer")
|
||||
.prefetch_related("photos")
|
||||
)
|
||||
"""Get filtered rides using the advanced search service"""
|
||||
# Initialize search service
|
||||
search_service = RideSearchService()
|
||||
|
||||
# Park filter
|
||||
# Parse filters from request
|
||||
filter_form = MasterFilterForm(self.request.GET)
|
||||
|
||||
# Apply park context if available
|
||||
park = None
|
||||
if "park_slug" in self.kwargs:
|
||||
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
||||
queryset = queryset.filter(park=self.park)
|
||||
park = self.park
|
||||
|
||||
# Search term handling
|
||||
search = self.request.GET.get("q", "").strip()
|
||||
if search:
|
||||
# Split search terms for more flexible matching
|
||||
search_terms = search.split()
|
||||
search_query = Q()
|
||||
|
||||
for term in search_terms:
|
||||
term_query = (
|
||||
Q(name__icontains=term)
|
||||
| Q(park__name__icontains=term)
|
||||
| Q(description__icontains=term)
|
||||
)
|
||||
search_query &= term_query
|
||||
|
||||
queryset = queryset.filter(search_query)
|
||||
|
||||
# Category filter
|
||||
category = self.request.GET.get("category")
|
||||
if category and category != "all":
|
||||
queryset = queryset.filter(category=category)
|
||||
|
||||
# Operating status filter
|
||||
if self.request.GET.get("operating") == "true":
|
||||
queryset = queryset.filter(status="operating")
|
||||
if filter_form.is_valid():
|
||||
# Use advanced search service
|
||||
queryset = search_service.search_rides(
|
||||
filters=filter_form.get_filter_dict(), park=park
|
||||
)
|
||||
else:
|
||||
# Fallback to basic queryset with park filter
|
||||
queryset = (
|
||||
Ride.objects.all()
|
||||
.select_related("park", "ride_model", "ride_model__manufacturer")
|
||||
.prefetch_related("photos")
|
||||
)
|
||||
if park:
|
||||
queryset = queryset.filter(park=park)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_template_names(self):
|
||||
"""Return appropriate template based on request type"""
|
||||
if self.request.htmx:
|
||||
if hasattr(self.request, "htmx") and self.request.htmx:
|
||||
return ["rides/partials/ride_list_results.html"]
|
||||
return [self.template_name]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add park and category choices to context"""
|
||||
"""Add filter form and context data"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Add park context
|
||||
if hasattr(self, "park"):
|
||||
context["park"] = self.park
|
||||
context["park_slug"] = self.kwargs["park_slug"]
|
||||
|
||||
# Add filter form
|
||||
filter_form = MasterFilterForm(self.request.GET)
|
||||
context["filter_form"] = filter_form
|
||||
context["category_choices"] = Categories
|
||||
|
||||
# Add filter summary for display
|
||||
if filter_form.is_valid():
|
||||
context["active_filters"] = filter_form.get_filter_summary()
|
||||
context["has_filters"] = filter_form.has_active_filters()
|
||||
else:
|
||||
context["active_filters"] = {}
|
||||
context["has_filters"] = False
|
||||
|
||||
# Add total count before filtering (for display purposes)
|
||||
if hasattr(self, "park"):
|
||||
context["total_rides"] = Ride.objects.filter(park=self.park).count()
|
||||
else:
|
||||
context["total_rides"] = Ride.objects.count()
|
||||
|
||||
# Add filtered count
|
||||
context["filtered_count"] = self.get_queryset().count()
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@@ -457,7 +474,7 @@ class RideSearchView(ListView):
|
||||
|
||||
|
||||
class RideRankingsView(ListView):
|
||||
"""View for displaying ride rankings using the Internet Roller Coaster Poll algorithm."""
|
||||
"""View for displaying ride rankings using the Internet Roller Coaster Poll."""
|
||||
|
||||
model = RideRanking
|
||||
template_name = "rides/rankings.html"
|
||||
|
||||
169
backend/docs/ride_filtering_design.md
Normal file
169
backend/docs/ride_filtering_design.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Comprehensive Ride Filtering System Design
|
||||
|
||||
## Overview
|
||||
This document outlines the design for a robust ride filtering system with PostgreSQL full-text search capabilities, organized in logical filter categories with a beautiful Tailwind 4 UI.
|
||||
|
||||
## Filter Categories
|
||||
|
||||
### 1. Search & Text Filters
|
||||
- **Main Search Bar**: Full-text search across name, description, park name using PostgreSQL SearchVector
|
||||
- **Advanced Search**: Separate fields for ride name, park name, description
|
||||
- **Fuzzy Search**: TrigramSimilarity for typo-tolerant searching
|
||||
|
||||
### 2. Basic Ride Information
|
||||
- **Category**: Multi-select dropdown (Roller Coaster, Dark Ride, Flat Ride, Water Ride, Transport, Other)
|
||||
- **Status**: Multi-select dropdown (Operating, Temporarily Closed, SBNO, Closing, Permanently Closed, Under Construction, Demolished, Relocated)
|
||||
|
||||
### 3. Date Filters
|
||||
- **Opening Date Range**: Date picker for from/to dates
|
||||
- **Closing Date Range**: Date picker for from/to dates
|
||||
- **Status Since**: Date picker for from/to dates
|
||||
|
||||
### 4. Height & Safety Requirements
|
||||
- **Minimum Height**: Range slider (30-90 inches)
|
||||
- **Maximum Height**: Range slider (30-90 inches)
|
||||
- **Height Requirement Type**: No requirement, minimum only, maximum only, both
|
||||
|
||||
### 5. Performance Metrics
|
||||
- **Capacity per Hour**: Range slider (0-5000+ people)
|
||||
- **Ride Duration**: Range slider (0-600+ seconds)
|
||||
- **Average Rating**: Range slider (1-10 stars)
|
||||
|
||||
### 6. Location & Relationships
|
||||
- **Park**: Multi-select autocomplete dropdown
|
||||
- **Park Area**: Multi-select dropdown (filtered by selected parks)
|
||||
- **Manufacturer**: Multi-select autocomplete dropdown
|
||||
- **Designer**: Multi-select autocomplete dropdown
|
||||
- **Ride Model**: Multi-select autocomplete dropdown
|
||||
|
||||
### 7. Roller Coaster Specific (only shown when RC category selected)
|
||||
- **Height**: Range slider (0-500+ feet)
|
||||
- **Length**: Range slider (0-10000+ feet)
|
||||
- **Speed**: Range slider (0-150+ mph)
|
||||
- **Inversions**: Range slider (0-20+)
|
||||
- **Max Drop Height**: Range slider (0-500+ feet)
|
||||
- **Track Material**: Multi-select (Steel, Wood, Hybrid)
|
||||
- **Coaster Type**: Multi-select (Sit Down, Inverted, Flying, Stand Up, Wing, Dive, Family, Wild Mouse, Spinning, 4th Dimension, Other)
|
||||
- **Launch Type**: Multi-select (Chain Lift, LSM Launch, Hydraulic Launch, Gravity, Other)
|
||||
|
||||
### 8. Company Information (when manufacturer/designer filters are used)
|
||||
- **Company Founded**: Date range picker
|
||||
- **Company Roles**: Multi-select (Manufacturer, Designer, Operator, Property Owner)
|
||||
- **Rides Count**: Range slider (1-500+)
|
||||
- **Coasters Count**: Range slider (1-200+)
|
||||
|
||||
## Sorting Options
|
||||
- **Name** (A-Z, Z-A)
|
||||
- **Opening Date** (Newest, Oldest)
|
||||
- **Average Rating** (Highest, Lowest)
|
||||
- **Height** (Tallest, Shortest) - for roller coasters
|
||||
- **Speed** (Fastest, Slowest) - for roller coasters
|
||||
- **Capacity** (Highest, Lowest)
|
||||
- **Recently Updated** (using pghistory)
|
||||
- **Relevance** (for text searches using SearchRank)
|
||||
|
||||
## UI/UX Design Principles
|
||||
|
||||
### Layout Structure
|
||||
- **Desktop**: Sidebar filter panel (collapsible) + main content area
|
||||
- **Mobile**: Overlay filter modal with bottom sheet design
|
||||
- **Filter Organization**: Collapsible sections with clean headers and icons
|
||||
|
||||
### Tailwind 4 Design System
|
||||
- **Color Scheme**: Modern neutral palette with theme support
|
||||
- **Typography**: Clear hierarchy with proper contrast
|
||||
- **Spacing**: Consistent spacing scale (4, 8, 16, 24, 32px)
|
||||
- **Interactive Elements**: Smooth transitions and hover states
|
||||
- **Form Controls**: Custom-styled inputs, dropdowns, sliders
|
||||
|
||||
### Dark Mode Support
|
||||
- Full dark mode implementation using Tailwind's dark: variants
|
||||
- Theme toggle in header
|
||||
- Proper contrast ratios for accessibility
|
||||
- Dark-friendly color selections for data visualization
|
||||
|
||||
### Responsive Design
|
||||
- **Mobile First**: Progressive enhancement approach
|
||||
- **Breakpoints**: sm (640px), md (768px), lg (1024px), xl (1280px)
|
||||
- **Touch Friendly**: Larger touch targets on mobile
|
||||
- **Collapsible Filters**: Smart grouping on smaller screens
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Backend Architecture
|
||||
1. **Search Service**: Django service layer for search logic
|
||||
2. **PostgreSQL Features**: SearchVector, SearchQuery, SearchRank, GIN indexes
|
||||
3. **Fuzzy Matching**: TrigramSimilarity for typo tolerance
|
||||
4. **Caching**: Redis for popular filter combinations
|
||||
5. **Pagination**: Efficient pagination with search ranking
|
||||
|
||||
### Frontend Features
|
||||
1. **HTMX Integration**: Real-time filtering without full page reloads
|
||||
2. **URL Persistence**: Filter state preserved in URL parameters
|
||||
3. **Autocomplete**: Intelligent suggestions for text fields
|
||||
4. **Filter Counts**: Show result counts for each filter option
|
||||
5. **Active Filters**: Visual indicators of applied filters
|
||||
6. **Reset Functionality**: Clear individual or all filters
|
||||
|
||||
### Performance Optimizations
|
||||
1. **Database Indexes**: GIN indexes on search vectors
|
||||
2. **Query Optimization**: Efficient JOIN strategies
|
||||
3. **Caching Layer**: Popular searches cached in Redis
|
||||
4. **Debounced Input**: Prevent excessive API calls
|
||||
5. **Lazy Loading**: Load expensive filter options on demand
|
||||
|
||||
## Accessibility Features
|
||||
- **Keyboard Navigation**: Full keyboard support for all controls
|
||||
- **Screen Readers**: Proper ARIA labels and descriptions
|
||||
- **Color Contrast**: WCAG AA compliance
|
||||
- **Focus Management**: Clear focus indicators
|
||||
- **Alternative Text**: Descriptive labels for all controls
|
||||
|
||||
## Search Algorithm Details
|
||||
|
||||
### PostgreSQL Full-Text Search
|
||||
```sql
|
||||
-- Search vector combining multiple fields with weights
|
||||
search_vector = (
|
||||
setweight(to_tsvector('english', name), 'A') ||
|
||||
setweight(to_tsvector('english', park.name), 'B') ||
|
||||
setweight(to_tsvector('english', description), 'C') ||
|
||||
setweight(to_tsvector('english', manufacturer.name), 'D')
|
||||
)
|
||||
|
||||
-- Search query with ranking
|
||||
SELECT *, ts_rank(search_vector, query) as rank
|
||||
FROM rides
|
||||
WHERE search_vector @@ query
|
||||
ORDER BY rank DESC, name
|
||||
```
|
||||
|
||||
### Trigram Similarity
|
||||
```sql
|
||||
-- Fuzzy matching for typos
|
||||
SELECT *
|
||||
FROM rides
|
||||
WHERE similarity(name, 'search_term') > 0.3
|
||||
ORDER BY similarity(name, 'search_term') DESC
|
||||
```
|
||||
|
||||
## Filter State Management
|
||||
- **URL Parameters**: All filter state stored in URL for shareability
|
||||
- **Local Storage**: Remember user preferences
|
||||
- **Session State**: Maintain state during browsing session
|
||||
- **Bookmark Support**: Full filter state can be bookmarked
|
||||
|
||||
## Testing Strategy
|
||||
1. **Unit Tests**: Individual filter components
|
||||
2. **Integration Tests**: Full filtering workflows
|
||||
3. **Performance Tests**: Large dataset filtering
|
||||
4. **Accessibility Tests**: Screen reader and keyboard testing
|
||||
5. **Mobile Tests**: Touch interface testing
|
||||
6. **Browser Tests**: Cross-browser compatibility
|
||||
|
||||
## Future Enhancements
|
||||
1. **Saved Filters**: User accounts can save filter combinations
|
||||
2. **Advanced Analytics**: Popular filter combinations tracking
|
||||
3. **Machine Learning**: Personalized filter suggestions
|
||||
4. **Export Features**: Export filtered results to CSV/PDF
|
||||
5. **Comparison Mode**: Side-by-side ride comparisons
|
||||
465
backend/templates/rides/partials/filter_sidebar.html
Normal file
465
backend/templates/rides/partials/filter_sidebar.html
Normal file
@@ -0,0 +1,465 @@
|
||||
{% load static %}
|
||||
|
||||
<!-- Advanced Ride Filters Sidebar -->
|
||||
<div class="filter-sidebar bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 h-full overflow-y-auto">
|
||||
<!-- Filter Header -->
|
||||
<div class="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 p-4 z-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-filter mr-2 text-blue-600 dark:text-blue-400"></i>
|
||||
Filters
|
||||
</h2>
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Filter Count Badge -->
|
||||
{% if has_filters %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{{ active_filters|length }} active
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Clear All Filters -->
|
||||
{% if has_filters %}
|
||||
<button type="button"
|
||||
hx-get="{% url 'rides:ride_list' %}"
|
||||
hx-target="#filter-results"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 font-medium">
|
||||
Clear All
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Filters Summary -->
|
||||
{% if has_filters %}
|
||||
<div class="mt-3 space-y-1">
|
||||
{% for category, filters in active_filters.items %}
|
||||
{% if filters %}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for filter_name, filter_value in filters.items %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
|
||||
{{ filter_name }}: {{ filter_value }}
|
||||
<button type="button"
|
||||
class="ml-1 h-3 w-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
onclick="removeFilter('{{ category }}', '{{ filter_name }}')">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Filter Form -->
|
||||
<form id="filter-form"
|
||||
hx-get="{% url 'rides:ride_list' %}"
|
||||
hx-target="#filter-results"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="change, input delay:500ms"
|
||||
hx-push-url="true"
|
||||
class="space-y-1">
|
||||
|
||||
<!-- Search Text Filter -->
|
||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="search-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-search mr-2 text-gray-500"></i>
|
||||
Search
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="search-section" class="filter-content p-4 space-y-3">
|
||||
{{ filter_form.search_text.label_tag }}
|
||||
{{ filter_form.search_text }}
|
||||
|
||||
<div class="mt-2">
|
||||
<label class="flex items-center text-sm">
|
||||
{{ filter_form.search_exact }}
|
||||
<span class="ml-2 text-gray-600 dark:text-gray-400">Exact match</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Basic Info Filter -->
|
||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="basic-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2 text-gray-500"></i>
|
||||
Basic Info
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="basic-section" class="filter-content p-4 space-y-4">
|
||||
<!-- Categories -->
|
||||
<div>
|
||||
{{ filter_form.categories.label_tag }}
|
||||
{{ filter_form.categories }}
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div>
|
||||
{{ filter_form.status.label_tag }}
|
||||
{{ filter_form.status }}
|
||||
</div>
|
||||
|
||||
<!-- Parks -->
|
||||
<div>
|
||||
{{ filter_form.parks.label_tag }}
|
||||
{{ filter_form.parks }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Filters -->
|
||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="date-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-calendar mr-2 text-gray-500"></i>
|
||||
Date Ranges
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="date-section" class="filter-content p-4 space-y-4">
|
||||
<!-- Opening Date Range -->
|
||||
<div>
|
||||
{{ filter_form.opening_date_range.label_tag }}
|
||||
{{ filter_form.opening_date_range }}
|
||||
</div>
|
||||
|
||||
<!-- Closing Date Range -->
|
||||
<div>
|
||||
{{ filter_form.closing_date_range.label_tag }}
|
||||
{{ filter_form.closing_date_range }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Height & Safety -->
|
||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="height-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-ruler-vertical mr-2 text-gray-500"></i>
|
||||
Height & Safety
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="height-section" class="filter-content p-4 space-y-4">
|
||||
<!-- Height Requirements -->
|
||||
<div>
|
||||
{{ filter_form.height_requirements.label_tag }}
|
||||
{{ filter_form.height_requirements }}
|
||||
</div>
|
||||
|
||||
<!-- Height Range -->
|
||||
<div>
|
||||
{{ filter_form.height_range.label_tag }}
|
||||
{{ filter_form.height_range }}
|
||||
</div>
|
||||
|
||||
<!-- Accessibility -->
|
||||
<div>
|
||||
{{ filter_form.accessibility_features.label_tag }}
|
||||
{{ filter_form.accessibility_features }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance -->
|
||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="performance-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-tachometer-alt mr-2 text-gray-500"></i>
|
||||
Performance
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="performance-section" class="filter-content p-4 space-y-4">
|
||||
<!-- Speed Range -->
|
||||
<div>
|
||||
{{ filter_form.speed_range.label_tag }}
|
||||
{{ filter_form.speed_range }}
|
||||
</div>
|
||||
|
||||
<!-- Duration Range -->
|
||||
<div>
|
||||
{{ filter_form.duration_range.label_tag }}
|
||||
{{ filter_form.duration_range }}
|
||||
</div>
|
||||
|
||||
<!-- Capacity Range -->
|
||||
<div>
|
||||
{{ filter_form.capacity_range.label_tag }}
|
||||
{{ filter_form.capacity_range }}
|
||||
</div>
|
||||
|
||||
<!-- Intensity -->
|
||||
<div>
|
||||
{{ filter_form.intensity_levels.label_tag }}
|
||||
{{ filter_form.intensity_levels }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Relationships -->
|
||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="relationships-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-sitemap mr-2 text-gray-500"></i>
|
||||
Companies & Models
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="relationships-section" class="filter-content p-4 space-y-4">
|
||||
<!-- Manufacturers -->
|
||||
<div>
|
||||
{{ filter_form.manufacturers.label_tag }}
|
||||
{{ filter_form.manufacturers }}
|
||||
</div>
|
||||
|
||||
<!-- Designers -->
|
||||
<div>
|
||||
{{ filter_form.designers.label_tag }}
|
||||
{{ filter_form.designers }}
|
||||
</div>
|
||||
|
||||
<!-- Ride Models -->
|
||||
<div>
|
||||
{{ filter_form.ride_models.label_tag }}
|
||||
{{ filter_form.ride_models }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roller Coaster Specific -->
|
||||
<div class="filter-section border-b border-gray-200 dark:border-gray-700">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="coaster-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-mountain mr-2 text-gray-500"></i>
|
||||
Roller Coaster Details
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="coaster-section" class="filter-content p-4 space-y-4">
|
||||
<!-- Track Type -->
|
||||
<div>
|
||||
{{ filter_form.track_types.label_tag }}
|
||||
{{ filter_form.track_types }}
|
||||
</div>
|
||||
|
||||
<!-- Launch Types -->
|
||||
<div>
|
||||
{{ filter_form.launch_types.label_tag }}
|
||||
{{ filter_form.launch_types }}
|
||||
</div>
|
||||
|
||||
<!-- Inversions Range -->
|
||||
<div>
|
||||
{{ filter_form.inversions_range.label_tag }}
|
||||
{{ filter_form.inversions_range }}
|
||||
</div>
|
||||
|
||||
<!-- Drop Range -->
|
||||
<div>
|
||||
{{ filter_form.drop_range.label_tag }}
|
||||
{{ filter_form.drop_range }}
|
||||
</div>
|
||||
|
||||
<!-- Length Range -->
|
||||
<div>
|
||||
{{ filter_form.length_range.label_tag }}
|
||||
{{ filter_form.length_range }}
|
||||
</div>
|
||||
|
||||
<!-- Has Features -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Features</label>
|
||||
<div class="space-y-1">
|
||||
<label class="flex items-center text-sm">
|
||||
{{ filter_form.has_inversions }}
|
||||
<span class="ml-2 text-gray-600 dark:text-gray-400">Has Inversions</span>
|
||||
</label>
|
||||
<label class="flex items-center text-sm">
|
||||
{{ filter_form.has_launch }}
|
||||
<span class="ml-2 text-gray-600 dark:text-gray-400">Has Launch System</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sorting -->
|
||||
<div class="filter-section">
|
||||
<button type="button"
|
||||
class="filter-toggle w-full px-4 py-3 text-left font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none"
|
||||
data-target="sorting-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-sort mr-2 text-gray-500"></i>
|
||||
Sorting
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transform transition-transform duration-200"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div id="sorting-section" class="filter-content p-4 space-y-4">
|
||||
<!-- Sort By -->
|
||||
<div>
|
||||
{{ filter_form.sort_by.label_tag }}
|
||||
{{ filter_form.sort_by }}
|
||||
</div>
|
||||
|
||||
<!-- Sort Order -->
|
||||
<div>
|
||||
{{ filter_form.sort_order.label_tag }}
|
||||
{{ filter_form.sort_order }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Filter JavaScript -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize collapsible sections
|
||||
initializeFilterSections();
|
||||
|
||||
// Initialize filter form handlers
|
||||
initializeFilterForm();
|
||||
});
|
||||
|
||||
function initializeFilterSections() {
|
||||
const toggles = document.querySelectorAll('.filter-toggle');
|
||||
|
||||
toggles.forEach(toggle => {
|
||||
toggle.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const content = document.getElementById(targetId);
|
||||
const chevron = this.querySelector('.fa-chevron-down');
|
||||
|
||||
if (content.style.display === 'none' || content.style.display === '') {
|
||||
content.style.display = 'block';
|
||||
chevron.style.transform = 'rotate(180deg)';
|
||||
localStorage.setItem(`filter-${targetId}`, 'open');
|
||||
} else {
|
||||
content.style.display = 'none';
|
||||
chevron.style.transform = 'rotate(0deg)';
|
||||
localStorage.setItem(`filter-${targetId}`, 'closed');
|
||||
}
|
||||
});
|
||||
|
||||
// Restore section state from localStorage
|
||||
const targetId = toggle.getAttribute('data-target');
|
||||
const content = document.getElementById(targetId);
|
||||
const chevron = toggle.querySelector('.fa-chevron-down');
|
||||
const state = localStorage.getItem(`filter-${targetId}`);
|
||||
|
||||
if (state === 'closed') {
|
||||
content.style.display = 'none';
|
||||
chevron.style.transform = 'rotate(0deg)';
|
||||
} else {
|
||||
content.style.display = 'block';
|
||||
chevron.style.transform = 'rotate(180deg)';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initializeFilterForm() {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (!form) return;
|
||||
|
||||
// Handle multi-select changes
|
||||
const selects = form.querySelectorAll('select[multiple]');
|
||||
selects.forEach(select => {
|
||||
select.addEventListener('change', function() {
|
||||
// Trigger HTMX update
|
||||
htmx.trigger(form, 'change');
|
||||
});
|
||||
});
|
||||
|
||||
// Handle range inputs
|
||||
const rangeInputs = form.querySelectorAll('input[type="range"], input[type="number"]');
|
||||
rangeInputs.forEach(input => {
|
||||
input.addEventListener('input', function() {
|
||||
// Debounced update
|
||||
clearTimeout(this.updateTimeout);
|
||||
this.updateTimeout = setTimeout(() => {
|
||||
htmx.trigger(form, 'input');
|
||||
}, 500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeFilter(category, filterName) {
|
||||
const form = document.getElementById('filter-form');
|
||||
const input = form.querySelector(`[name*="${filterName}"]`);
|
||||
|
||||
if (input) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = false;
|
||||
} else if (input.tagName === 'SELECT') {
|
||||
if (input.multiple) {
|
||||
Array.from(input.options).forEach(option => option.selected = false);
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// Trigger form update
|
||||
htmx.trigger(form, 'change');
|
||||
}
|
||||
}
|
||||
|
||||
// Update filter counts
|
||||
function updateFilterCounts() {
|
||||
const form = document.getElementById('filter-form');
|
||||
const formData = new FormData(form);
|
||||
let activeCount = 0;
|
||||
|
||||
for (let [key, value] of formData.entries()) {
|
||||
if (value && value.trim() !== '') {
|
||||
activeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const badge = document.querySelector('.filter-count-badge');
|
||||
if (badge) {
|
||||
badge.textContent = activeCount;
|
||||
badge.style.display = activeCount > 0 ? 'inline-flex' : 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,105 +1,326 @@
|
||||
{% load ride_tags %}
|
||||
<!-- Active filters display (mobile and desktop) -->
|
||||
{% if has_filters %}
|
||||
<div class="mb-6">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Active Filters ({{ active_filters|length }})
|
||||
</h3>
|
||||
<button hx-get="{% url 'rides:ride_list' %}{% if park %}?park={{ park.id }}{% endif %}"
|
||||
hx-target="#filter-results"
|
||||
hx-push-url="true"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 transition-colors">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for filter_key, filter_info in active_filters.items %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100">
|
||||
{{ filter_info.label }}: {{ filter_info.value }}
|
||||
<button hx-get="{{ filter_info.remove_url }}"
|
||||
hx-target="#filter-results"
|
||||
hx-push-url="true"
|
||||
class="ml-1 text-blue-600 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-100">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Rides Grid -->
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for ride in rides %}
|
||||
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||
<div class="aspect-w-16 aspect-h-9">
|
||||
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
|
||||
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
|
||||
{{ ride.name }}
|
||||
{% if ride.photos.exists %}
|
||||
<img src="{{ ride.photos.first.image.url }}"
|
||||
alt="{{ ride.name }}"
|
||||
class="object-cover w-full">
|
||||
{% else %}
|
||||
<img src="{% get_ride_placeholder_image ride.category %}"
|
||||
alt="{{ ride.name }}"
|
||||
class="object-cover w-full">
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h2 class="mb-2 text-xl font-bold">
|
||||
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
|
||||
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
|
||||
{{ ride.name }}
|
||||
</a>
|
||||
</h2>
|
||||
{% if not park %}
|
||||
<p class="mb-3 text-gray-600 dark:text-gray-400">
|
||||
at <a href="{% url 'parks:park_detail' ride.park.slug %}"
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
{{ ride.park.name }}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-400/30 dark:text-blue-200 dark:ring-1 dark:ring-blue-400/30">
|
||||
{{ ride.get_category_display }}
|
||||
</span>
|
||||
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
|
||||
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
|
||||
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||
{% elif ride.status == 'DEMOLISHED' %}status-demolished
|
||||
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||
{{ ride.get_status_display }}
|
||||
</span>
|
||||
{% if ride.average_rating %}
|
||||
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
|
||||
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
|
||||
{{ ride.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if ride.coaster_stats %}
|
||||
<div class="grid grid-cols-2 gap-2 mt-4">
|
||||
{% if ride.coaster_stats.height_ft %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Height: {{ ride.coaster_stats.height_ft }}ft
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if ride.coaster_stats.speed_mph %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Speed: {{ ride.coaster_stats.speed_mph }}mph
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Results header with count and sorting -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6">
|
||||
<div class="mb-4 sm:mb-0">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{% if filtered_count == total_rides %}
|
||||
All {{ total_rides }} ride{{ total_rides|pluralize }}
|
||||
{% else %}
|
||||
{{ filtered_count }} of {{ total_rides }} ride{{ total_rides|pluralize }}
|
||||
{% endif %}
|
||||
{% if park %}
|
||||
at {{ park.name }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% if query %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
<i class="fas fa-search mr-1"></i>
|
||||
Searching for: "{{ query }}"
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Sort options -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<label for="sort-select" class="text-sm font-medium text-gray-700 dark:text-gray-300">Sort by:</label>
|
||||
<select id="sort-select"
|
||||
name="sort"
|
||||
hx-get="{% url 'rides:ride_list' %}"
|
||||
hx-target="#filter-results"
|
||||
hx-include="[name='q'], [name='category'], [name='manufacturer'], [name='designer'], [name='min_height'], [name='max_height'], [name='min_speed'], [name='max_speed'], [name='min_capacity'], [name='max_capacity'], [name='min_duration'], [name='max_duration'], [name='opened_after'], [name='opened_before'], [name='closed_after'], [name='closed_before'], [name='operating_status'], [name='has_inversions'], [name='has_launches'], [name='track_type'], [name='min_inversions'], [name='max_inversions'], [name='min_launches'], [name='max_launches'], [name='min_top_speed'], [name='max_top_speed'], [name='min_max_height'], [name='max_max_height']{% if park %}, [name='park']{% endif %}"
|
||||
hx-push-url="true"
|
||||
class="form-select text-sm">
|
||||
<option value="name" {% if current_sort == 'name' %}selected{% endif %}>Name (A-Z)</option>
|
||||
<option value="-name" {% if current_sort == '-name' %}selected{% endif %}>Name (Z-A)</option>
|
||||
<option value="-opened_date" {% if current_sort == '-opened_date' %}selected{% endif %}>Newest First</option>
|
||||
<option value="opened_date" {% if current_sort == 'opened_date' %}selected{% endif %}>Oldest First</option>
|
||||
<option value="-height" {% if current_sort == '-height' %}selected{% endif %}>Tallest First</option>
|
||||
<option value="height" {% if current_sort == 'height' %}selected{% endif %}>Shortest First</option>
|
||||
<option value="-max_speed" {% if current_sort == '-max_speed' %}selected{% endif %}>Fastest First</option>
|
||||
<option value="max_speed" {% if current_sort == 'max_speed' %}selected{% endif %}>Slowest First</option>
|
||||
<option value="-capacity_per_hour" {% if current_sort == '-capacity_per_hour' %}selected{% endif %}>Highest Capacity</option>
|
||||
<option value="capacity_per_hour" {% if current_sort == 'capacity_per_hour' %}selected{% endif %}>Lowest Capacity</option>
|
||||
<option value="relevance" {% if current_sort == 'relevance' %}selected{% endif %}>Most Relevant</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results grid -->
|
||||
{% if rides %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{% for ride in rides %}
|
||||
<div class="ride-card bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-lg transition-all duration-300">
|
||||
<!-- Ride image -->
|
||||
<div class="relative h-48 bg-gradient-to-br from-blue-500 to-purple-600">
|
||||
{% if ride.image %}
|
||||
<img src="{{ ride.image.url }}"
|
||||
alt="{{ ride.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<i class="fas fa-rocket text-4xl text-white opacity-50"></i>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-span-3 py-8 text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">No rides found matching your criteria.</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Status badge -->
|
||||
<div class="absolute top-3 right-3">
|
||||
{% if ride.operating_status == 'operating' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<i class="fas fa-play-circle mr-1"></i>
|
||||
Operating
|
||||
</span>
|
||||
{% elif ride.operating_status == 'closed_temporarily' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
<i class="fas fa-pause-circle mr-1"></i>
|
||||
Temporarily Closed
|
||||
</span>
|
||||
{% elif ride.operating_status == 'closed_permanently' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
<i class="fas fa-stop-circle mr-1"></i>
|
||||
Permanently Closed
|
||||
</span>
|
||||
{% elif ride.operating_status == 'under_construction' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
<i class="fas fa-hard-hat mr-1"></i>
|
||||
Under Construction
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ride details -->
|
||||
<div class="p-5">
|
||||
<!-- Name and category -->
|
||||
<div class="mb-3">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
<a href="{% url 'rides:ride_detail' ride.id %}"
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{{ ride.name }}
|
||||
</a>
|
||||
</h3>
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 mr-2">
|
||||
{{ ride.category|default:"Ride" }}
|
||||
</span>
|
||||
{% if ride.park %}
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-map-marker-alt mr-1"></i>
|
||||
{{ ride.park.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Key stats -->
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
{% if ride.height %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.height }}ft</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Height</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.rollercoaster_stats.max_speed %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.rollercoaster_stats.max_speed }}mph</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Top Speed</div>
|
||||
</div>
|
||||
{% elif ride.max_speed %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.max_speed }}mph</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Max Speed</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.capacity_per_hour %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.capacity_per_hour }}</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Capacity/Hr</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.duration %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.duration }}s</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Duration</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Special features -->
|
||||
{% if ride.has_inversions or ride.has_launches or ride.rollercoaster_stats %}
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
{% if ride.has_inversions %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
<i class="fas fa-sync-alt mr-1"></i>
|
||||
{% if ride.rollercoaster_stats.number_of_inversions %}
|
||||
{{ ride.rollercoaster_stats.number_of_inversions }} Inversion{{ ride.rollercoaster_stats.number_of_inversions|pluralize }}
|
||||
{% else %}
|
||||
Inversions
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.has_launches %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
<i class="fas fa-rocket mr-1"></i>
|
||||
{% if ride.rollercoaster_stats.number_of_launches %}
|
||||
{{ ride.rollercoaster_stats.number_of_launches }} Launch{{ ride.rollercoaster_stats.number_of_launches|pluralize }}
|
||||
{% else %}
|
||||
Launched
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.rollercoaster_stats.track_type %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{{ ride.rollercoaster_stats.track_type|title }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Opening date -->
|
||||
{% if ride.opened_date %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
<i class="fas fa-calendar mr-1"></i>
|
||||
Opened {{ ride.opened_date|date:"F j, Y" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Manufacturer -->
|
||||
{% if ride.manufacturer %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-industry mr-1"></i>
|
||||
{{ ride.manufacturer.name }}
|
||||
{% if ride.designer and ride.designer != ride.manufacturer %}
|
||||
• Designed by {{ ride.designer.name }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<div class="flex justify-center mt-6" hx-target="#ride-list-results" hx-push-url="false">
|
||||
<div class="inline-flex rounded-md shadow-xs">
|
||||
{% if page_obj.has_previous %}
|
||||
<a hx-get="?page=1{{ request.GET.urlencode }}"
|
||||
hx-target="#ride-list-results"
|
||||
class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">« First</a>
|
||||
<a hx-get="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}"
|
||||
hx-target="#ride-list-results"
|
||||
class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a hx-get="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}"
|
||||
hx-target="#ride-list-results"
|
||||
class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
|
||||
<a hx-get="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}"
|
||||
hx-target="#ride-list-results"
|
||||
class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last »</a>
|
||||
<div class="mt-8 flex items-center justify-between">
|
||||
<div class="flex items-center text-sm text-gray-700 dark:text-gray-300">
|
||||
<p>
|
||||
Showing {{ page_obj.start_index }} to {{ page_obj.end_index }} of {{ filtered_count }} results
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}"
|
||||
hx-get="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}"
|
||||
hx-target="#filter-results"
|
||||
hx-push-url="true"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600">
|
||||
<i class="fas fa-chevron-left mr-1"></i>
|
||||
Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Page numbers -->
|
||||
<div class="hidden sm:flex items-center space-x-1">
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<span class="inline-flex items-center px-3 py-2 border border-blue-500 bg-blue-50 text-blue-600 rounded-md text-sm font-medium dark:bg-blue-900 dark:text-blue-200 dark:border-blue-700">
|
||||
{{ num }}
|
||||
</span>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ num }}"
|
||||
hx-get="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ num }}"
|
||||
hx-target="#filter-results"
|
||||
hx-push-url="true"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600">
|
||||
{{ num }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}"
|
||||
hx-get="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}"
|
||||
hx-target="#filter-results"
|
||||
hx-push-url="true"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600">
|
||||
Next
|
||||
<i class="fas fa-chevron-right ml-1"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- No results state -->
|
||||
<div class="text-center py-12">
|
||||
<div class="mx-auto h-24 w-24 text-gray-400 dark:text-gray-600 mb-4">
|
||||
<i class="fas fa-search text-6xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No rides found</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
{% if has_filters %}
|
||||
Try adjusting your filters to see more results.
|
||||
{% else %}
|
||||
No rides match your current search criteria.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if has_filters %}
|
||||
<button hx-get="{% url 'rides:ride_list' %}{% if park %}?park={{ park.id }}{% endif %}"
|
||||
hx-target="#filter-results"
|
||||
hx-push-url="true"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 transition-colors">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear All Filters
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- HTMX loading indicator -->
|
||||
<div class="htmx-indicator fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 z-50">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">Loading results...</span>
|
||||
</div>
|
||||
</div>
|
||||
258
backend/templates/rides/ride_list.html
Normal file
258
backend/templates/rides/ride_list.html
Normal file
@@ -0,0 +1,258 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}
|
||||
{% if park %}
|
||||
{{ park.name }} - Rides
|
||||
{% else %}
|
||||
All Rides
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
/* Custom styles for the ride filtering interface */
|
||||
.filter-sidebar {
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
max-height: calc(100vh - 4rem);
|
||||
position: sticky;
|
||||
top: 4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.filter-sidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-sidebar {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
position: relative;
|
||||
top: auto;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.filter-toggle:hover {
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.filter-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.filter-content.open {
|
||||
max-height: 1000px;
|
||||
}
|
||||
|
||||
/* Custom form styling */
|
||||
.form-input, .form-select, .form-textarea {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
dark:bg-gray-700 dark:border-gray-600 dark:text-white
|
||||
dark:focus:ring-blue-400 dark:focus:border-blue-400;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
@apply w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded
|
||||
focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800
|
||||
dark:bg-gray-700 dark:border-gray-600;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1;
|
||||
}
|
||||
|
||||
/* Ride card animations */
|
||||
.ride-card {
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.ride-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.htmx-request.htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Mobile filter overlay */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-filter-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.mobile-filter-panel {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
background: white;
|
||||
z-index: 50;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.mobile-filter-panel.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Mobile filter overlay -->
|
||||
<div id="mobile-filter-overlay" class="mobile-filter-overlay hidden lg:hidden"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="max-w-full px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Title and breadcrumb -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{% if park %}
|
||||
<i class="fas fa-map-marker-alt mr-2 text-blue-600 dark:text-blue-400"></i>
|
||||
{{ park.name }} - Rides
|
||||
{% else %}
|
||||
<i class="fas fa-rocket mr-2 text-blue-600 dark:text-blue-400"></i>
|
||||
All Rides
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<!-- Mobile filter toggle -->
|
||||
<button id="mobile-filter-toggle"
|
||||
class="lg:hidden inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Filters
|
||||
{% if has_filters %}
|
||||
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{{ active_filters|length }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results summary -->
|
||||
<div class="hidden sm:flex items-center space-x-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>
|
||||
<i class="fas fa-list mr-1"></i>
|
||||
{{ filtered_count }} of {{ total_rides }} rides
|
||||
</span>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div class="htmx-indicator">
|
||||
<i class="fas fa-spinner fa-spin text-blue-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="flex">
|
||||
<!-- Desktop filter sidebar -->
|
||||
<div class="hidden lg:block">
|
||||
{% include "rides/partials/filter_sidebar.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Mobile filter panel -->
|
||||
<div id="mobile-filter-panel" class="mobile-filter-panel lg:hidden dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Filters</h3>
|
||||
<button id="mobile-filter-close" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% include "rides/partials/filter_sidebar.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Results area -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div id="filter-results" class="p-4 lg:p-6">
|
||||
{% include "rides/partials/ride_list_results.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile filter JavaScript -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const mobileToggle = document.getElementById('mobile-filter-toggle');
|
||||
const mobilePanel = document.getElementById('mobile-filter-panel');
|
||||
const mobileOverlay = document.getElementById('mobile-filter-overlay');
|
||||
const mobileClose = document.getElementById('mobile-filter-close');
|
||||
|
||||
function openMobileFilter() {
|
||||
mobilePanel.classList.add('open');
|
||||
mobileOverlay.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeMobileFilter() {
|
||||
mobilePanel.classList.remove('open');
|
||||
mobileOverlay.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
if (mobileToggle) {
|
||||
mobileToggle.addEventListener('click', openMobileFilter);
|
||||
}
|
||||
|
||||
if (mobileClose) {
|
||||
mobileClose.addEventListener('click', closeMobileFilter);
|
||||
}
|
||||
|
||||
if (mobileOverlay) {
|
||||
mobileOverlay.addEventListener('click', closeMobileFilter);
|
||||
}
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && mobilePanel.classList.contains('open')) {
|
||||
closeMobileFilter();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Dark mode toggle (if not already implemented globally)
|
||||
function toggleDarkMode() {
|
||||
document.documentElement.classList.toggle('dark');
|
||||
localStorage.setItem('darkMode', document.documentElement.classList.contains('dark'));
|
||||
}
|
||||
|
||||
// Initialize dark mode from localStorage
|
||||
if (localStorage.getItem('darkMode') === 'true' ||
|
||||
(!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user