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:
pacnpal
2025-08-25 12:03:22 -04:00
parent dcf890a55c
commit bf7e0c0f40
37 changed files with 9350 additions and 143 deletions

View File

@@ -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

View File

@@ -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)),
]

View 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",
),
]

View 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})

View 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

View 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"
)

View 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

View File

@@ -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"),

View File

@@ -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"

View 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

View 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>

View File

@@ -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">&laquo; 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 &raquo;</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>

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