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

@@ -9,7 +9,12 @@
```bash
cd $PROJECT_ROOT/backend && uv add <package>
```
- **Django Commands:** Always use `uv run manage.py <command>` for all management tasks (migrations, shell, superuser, etc.). Never use `python manage.py` or `uv run python manage.py`.
- **Django Commands:** Always use `cd backend && uv run manage.py <command>` for all management tasks (migrations, shell, superuser, etc.). Never use `python manage.py` or `uv run python manage.py`.
## CRITICAL Frontend design rules
- EVERYTHING must support both dark and light mode.
- Make sure the light/dark mode toggle works with the Vue components and pages.
- Leverage Tailwind CSS 4 and Shadcn UI components.
## Frontend API URL Rules
- **Vite Proxy:** Always check `frontend/vite.config.ts` for proxy rules before changing frontend API URLs.

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

Binary file not shown.

View File

@@ -0,0 +1,109 @@
<template>
<div
class="active-filter-chip inline-flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-full text-sm"
:class="{
'cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-800': !disabled && removable,
'opacity-50': disabled,
}"
>
<!-- Filter icon -->
<Icon :name="icon" class="w-4 h-4 text-blue-600 dark:text-blue-300 flex-shrink-0" />
<!-- Filter label and value -->
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-blue-800 dark:text-blue-200 font-medium truncate">
{{ label }}:
</span>
<span class="text-blue-700 dark:text-blue-300 truncate">
{{ displayValue }}
</span>
</div>
<!-- Count indicator -->
<span
v-if="count !== undefined"
class="bg-blue-200 dark:bg-blue-700 text-blue-800 dark:text-blue-200 px-1.5 py-0.5 rounded-full text-xs font-medium"
>
{{ count }}
</span>
<!-- Remove button -->
<button
v-if="removable && !disabled"
@click="$emit('remove')"
class="flex items-center justify-center w-5 h-5 rounded-full hover:bg-blue-200 dark:hover:bg-blue-700 transition-colors group"
:aria-label="`Remove ${label} filter`"
>
<Icon
name="x"
class="w-3 h-3 text-blue-600 dark:text-blue-300 group-hover:text-blue-800 dark:group-hover:text-blue-100"
/>
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import Icon from "@/components/ui/Icon.vue";
interface Props {
label: string;
value: any;
icon?: string;
count?: number;
removable?: boolean;
disabled?: boolean;
formatValue?: (value: any) => string;
}
const props = withDefaults(defineProps<Props>(), {
icon: "filter",
removable: true,
disabled: false,
});
defineEmits<{
remove: [];
}>();
// Computed
const displayValue = computed(() => {
if (props.formatValue) {
return props.formatValue(props.value);
}
if (Array.isArray(props.value)) {
if (props.value.length === 1) {
return String(props.value[0]);
} else if (props.value.length <= 3) {
return props.value.join(", ");
} else {
return `${props.value.slice(0, 2).join(", ")} +${props.value.length - 2} more`;
}
}
if (typeof props.value === "object" && props.value !== null) {
// Handle range objects
if ("min" in props.value && "max" in props.value) {
return `${props.value.min} - ${props.value.max}`;
}
// Handle date range objects
if ("start" in props.value && "end" in props.value) {
return `${props.value.start} to ${props.value.end}`;
}
return JSON.stringify(props.value);
}
return String(props.value);
});
</script>
<style scoped>
.active-filter-chip {
@apply transition-all duration-200;
}
.active-filter-chip:hover {
@apply shadow-sm;
}
</style>

View File

@@ -0,0 +1,415 @@
<template>
<div class="date-range-filter">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ label }}
<span v-if="required" class="text-red-500 ml-1">*</span>
</label>
<!-- Date inputs -->
<div class="grid grid-cols-2 gap-3">
<!-- Start date -->
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1"> From </label>
<div class="relative">
<input
ref="startDateInput"
type="date"
:value="startDate"
@input="handleStartDateChange"
@blur="emitChange"
:min="minDate"
:max="endDate || maxDate"
class="date-input w-full px-3 py-2 text-sm border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
:disabled="disabled"
:placeholder="startPlaceholder"
/>
<div
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"
>
<Icon name="calendar" class="w-4 h-4 text-gray-400" />
</div>
</div>
</div>
<!-- End date -->
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1"> To </label>
<div class="relative">
<input
ref="endDateInput"
type="date"
:value="endDate"
@input="handleEndDateChange"
@blur="emitChange"
:min="startDate || minDate"
:max="maxDate"
class="date-input w-full px-3 py-2 text-sm border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
:disabled="disabled"
:placeholder="endPlaceholder"
/>
<div
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"
>
<Icon name="calendar" class="w-4 h-4 text-gray-400" />
</div>
</div>
</div>
</div>
<!-- Current selection display -->
<div v-if="hasSelection" class="mt-2 text-sm text-gray-600 dark:text-gray-400">
<span class="font-medium">Selected:</span>
{{ formatDateRange(startDate, endDate) }}
<span v-if="duration" class="text-gray-500 ml-2"> ({{ duration }}) </span>
</div>
<!-- Quick preset buttons -->
<div v-if="showPresets && presets.length > 0" class="mt-3">
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Quick Select
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presets"
:key="preset.label"
@click="applyPreset(preset)"
class="px-3 py-1 text-xs rounded-full border border-gray-300 hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700 transition-colors"
:class="{
'bg-blue-50 border-blue-300 text-blue-700 dark:bg-blue-900 dark:border-blue-600 dark:text-blue-200': isActivePreset(
preset
),
}"
:disabled="disabled"
>
{{ preset.label }}
</button>
</div>
</div>
<!-- Validation message -->
<div v-if="validationMessage" class="mt-2 text-sm text-red-600 dark:text-red-400">
{{ validationMessage }}
</div>
<!-- Clear button -->
<button
v-if="clearable && hasSelection"
@click="clearDates"
class="mt-3 text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
:disabled="disabled"
>
Clear dates
</button>
<!-- Helper text -->
<div v-if="helperText" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ helperText }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import Icon from "@/components/ui/Icon.vue";
interface DatePreset {
label: string;
startDate: string | (() => string);
endDate: string | (() => string);
}
interface Props {
label: string;
value?: [string, string];
minDate?: string;
maxDate?: string;
startPlaceholder?: string;
endPlaceholder?: string;
required?: boolean;
disabled?: boolean;
clearable?: boolean;
showPresets?: boolean;
helperText?: string;
validateRange?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
startPlaceholder: "Start date",
endPlaceholder: "End date",
required: false,
disabled: false,
clearable: true,
showPresets: true,
validateRange: true,
});
const emit = defineEmits<{
update: [value: [string, string] | undefined];
}>();
// Local state
const startDate = ref(props.value?.[0] || "");
const endDate = ref(props.value?.[1] || "");
const validationMessage = ref("");
// Computed
const hasSelection = computed(() => {
return Boolean(startDate.value && endDate.value);
});
const duration = computed(() => {
if (!startDate.value || !endDate.value) return null;
const start = new Date(startDate.value);
const end = new Date(endDate.value);
const diffTime = Math.abs(end.getTime() - start.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 1) return "1 day";
if (diffDays < 7) return `${diffDays} days`;
if (diffDays < 30) return `${Math.round(diffDays / 7)} weeks`;
if (diffDays < 365) return `${Math.round(diffDays / 30)} months`;
return `${Math.round(diffDays / 365)} years`;
});
const presets = computed((): DatePreset[] => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const lastWeek = new Date(today);
lastWeek.setDate(lastWeek.getDate() - 7);
const lastMonth = new Date(today);
lastMonth.setMonth(lastMonth.getMonth() - 1);
const lastYear = new Date(today);
lastYear.setFullYear(lastYear.getFullYear() - 1);
const thisYear = new Date(today.getFullYear(), 0, 1);
return [
{
label: "Today",
startDate: () => formatDate(today),
endDate: () => formatDate(today),
},
{
label: "Yesterday",
startDate: () => formatDate(yesterday),
endDate: () => formatDate(yesterday),
},
{
label: "Last 7 days",
startDate: () => formatDate(lastWeek),
endDate: () => formatDate(today),
},
{
label: "Last 30 days",
startDate: () => formatDate(lastMonth),
endDate: () => formatDate(today),
},
{
label: "This year",
startDate: () => formatDate(thisYear),
endDate: () => formatDate(today),
},
{
label: "Last year",
startDate: () => formatDate(new Date(lastYear.getFullYear(), 0, 1)),
endDate: () => formatDate(new Date(lastYear.getFullYear(), 11, 31)),
},
];
});
// Methods
const formatDate = (date: Date): string => {
return date.toISOString().split("T")[0];
};
const formatDateRange = (start: string, end: string): string => {
if (!start || !end) return "";
const startDate = new Date(start);
const endDate = new Date(end);
const formatOptions: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
};
if (start === end) {
return startDate.toLocaleDateString(undefined, formatOptions);
}
return `${startDate.toLocaleDateString(
undefined,
formatOptions
)} - ${endDate.toLocaleDateString(undefined, formatOptions)}`;
};
const validateDates = (): boolean => {
validationMessage.value = "";
if (!props.validateRange) return true;
if (startDate.value && endDate.value) {
const start = new Date(startDate.value);
const end = new Date(endDate.value);
if (start > end) {
validationMessage.value = "Start date cannot be after end date";
return false;
}
}
if (props.minDate && startDate.value) {
const start = new Date(startDate.value);
const min = new Date(props.minDate);
if (start < min) {
validationMessage.value = `Date cannot be before ${formatDateRange(
props.minDate,
props.minDate
)}`;
return false;
}
}
if (props.maxDate && endDate.value) {
const end = new Date(endDate.value);
const max = new Date(props.maxDate);
if (end > max) {
validationMessage.value = `Date cannot be after ${formatDateRange(
props.maxDate,
props.maxDate
)}`;
return false;
}
}
return true;
};
const handleStartDateChange = (event: Event) => {
const target = event.target as HTMLInputElement;
startDate.value = target.value;
// Auto-adjust end date if it's before start date
if (endDate.value && startDate.value > endDate.value) {
endDate.value = startDate.value;
}
};
const handleEndDateChange = (event: Event) => {
const target = event.target as HTMLInputElement;
endDate.value = target.value;
// Auto-adjust start date if it's after end date
if (startDate.value && endDate.value < startDate.value) {
startDate.value = endDate.value;
}
};
const emitChange = () => {
if (!validateDates()) return;
const hasValidRange = Boolean(startDate.value && endDate.value);
emit("update", hasValidRange ? [startDate.value, endDate.value] : undefined);
};
const applyPreset = (preset: DatePreset) => {
const start =
typeof preset.startDate === "function" ? preset.startDate() : preset.startDate;
const end = typeof preset.endDate === "function" ? preset.endDate() : preset.endDate;
startDate.value = start;
endDate.value = end;
emitChange();
};
const isActivePreset = (preset: DatePreset): boolean => {
if (!hasSelection.value) return false;
const start =
typeof preset.startDate === "function" ? preset.startDate() : preset.startDate;
const end = typeof preset.endDate === "function" ? preset.endDate() : preset.endDate;
return startDate.value === start && endDate.value === end;
};
const clearDates = () => {
startDate.value = "";
endDate.value = "";
validationMessage.value = "";
emit("update", undefined);
};
// Watch for prop changes
watch(
() => props.value,
(newValue) => {
if (newValue) {
startDate.value = newValue[0] || "";
endDate.value = newValue[1] || "";
} else {
startDate.value = "";
endDate.value = "";
}
validationMessage.value = "";
},
{ immediate: true }
);
</script>
<style scoped>
.date-input {
@apply transition-colors duration-200;
}
.date-input:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
.date-input:disabled {
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
}
/* Custom date picker styles */
.date-input::-webkit-calendar-picker-indicator {
opacity: 0;
position: absolute;
right: 0;
width: 20px;
height: 20px;
cursor: pointer;
}
.date-input::-webkit-datetime-edit {
@apply text-gray-900 dark:text-white;
}
.date-input::-webkit-datetime-edit-fields-wrapper {
@apply text-gray-900 dark:text-white;
}
.date-input::-webkit-datetime-edit-text {
@apply text-gray-500 dark:text-gray-400;
}
.date-input[type="date"]::-webkit-input-placeholder {
@apply text-gray-400 dark:text-gray-500;
}
/* Firefox */
.date-input[type="date"]::-moz-placeholder {
@apply text-gray-400 dark:text-gray-500;
}
/* Edge */
.date-input[type="date"]::-ms-input-placeholder {
@apply text-gray-400 dark:text-gray-500;
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div class="filter-section">
<button
@click="$emit('toggle')"
class="section-header w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:aria-expanded="isExpanded"
:aria-controls="`section-${id}`"
>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
{{ title }}
</h3>
<Icon
name="chevron-down"
class="w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform"
:class="{ 'rotate-180': isExpanded }"
/>
</button>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-96"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-96"
leave-to-class="opacity-0 max-h-0"
>
<div
v-show="isExpanded"
:id="`section-${id}`"
class="section-content overflow-hidden border-t border-gray-100 dark:border-gray-700"
>
<div class="p-4">
<slot />
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import Icon from "@/components/ui/Icon.vue";
interface Props {
id: string;
title: string;
isExpanded: boolean;
}
defineProps<Props>();
defineEmits<{
toggle: [];
}>();
</script>
<style scoped>
.filter-section {
@apply border-b border-gray-100 dark:border-gray-700;
}
.section-header {
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset;
}
.section-content {
@apply bg-gray-50 dark:bg-gray-800;
}
</style>

View File

@@ -0,0 +1,229 @@
<template>
<div
class="preset-item group flex items-center justify-between p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:class="{
'bg-blue-50 dark:bg-blue-900 border-blue-200 dark:border-blue-600': isActive,
'cursor-pointer': !disabled,
'opacity-50 cursor-not-allowed': disabled,
}"
@click="!disabled && $emit('select')"
>
<!-- Preset info -->
<div class="flex-1 min-w-0">
<!-- Name and description -->
<div class="flex items-center gap-2 mb-1">
<h4 class="font-medium text-gray-900 dark:text-white truncate">
{{ preset.name }}
</h4>
<span
v-if="isDefault"
class="px-2 py-0.5 text-xs bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-full"
>
Default
</span>
<span
v-if="isGlobal"
class="px-2 py-0.5 text-xs bg-green-100 dark:bg-green-800 text-green-700 dark:text-green-300 rounded-full"
>
Global
</span>
</div>
<!-- Description -->
<p
v-if="preset.description"
class="text-sm text-gray-600 dark:text-gray-400 truncate"
>
{{ preset.description }}
</p>
<!-- Filter count and last used -->
<div class="flex items-center gap-4 mt-2 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1">
<Icon name="filter" class="w-3 h-3" />
{{ filterCount }} {{ filterCount === 1 ? "filter" : "filters" }}
</span>
<span v-if="preset.lastUsed" class="flex items-center gap-1">
<Icon name="clock" class="w-3 h-3" />
{{ formatLastUsed(preset.lastUsed) }}
</span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 ml-4">
<!-- Star/favorite button -->
<button
v-if="!isDefault && showFavorite"
@click.stop="$emit('toggle-favorite')"
class="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
:class="{
'text-yellow-500': preset.isFavorite,
'text-gray-400 dark:text-gray-500': !preset.isFavorite,
}"
:aria-label="preset.isFavorite ? 'Remove from favorites' : 'Add to favorites'"
>
<Icon :name="preset.isFavorite ? 'star-filled' : 'star'" class="w-4 h-4" />
</button>
<!-- More actions menu -->
<div class="relative" v-if="showActions">
<button
@click.stop="showMenu = !showMenu"
class="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors text-gray-400 dark:text-gray-500"
:aria-label="'More actions for ' + preset.name"
>
<Icon name="more-vertical" class="w-4 h-4" />
</button>
<!-- Dropdown menu -->
<div
v-if="showMenu"
class="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-10"
@click.stop
>
<button
v-if="!isDefault"
@click="
$emit('rename');
showMenu = false;
"
class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Icon name="edit" class="w-4 h-4 inline mr-2" />
Rename
</button>
<button
@click="
$emit('duplicate');
showMenu = false;
"
class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Icon name="copy" class="w-4 h-4 inline mr-2" />
Duplicate
</button>
<button
v-if="!isDefault"
@click="
$emit('delete');
showMenu = false;
"
class="w-full px-4 py-2 text-left text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<Icon name="trash" class="w-4 h-4 inline mr-2" />
Delete
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import Icon from "@/components/ui/Icon.vue";
import type { FilterPreset } from "@/types/filters";
interface Props {
preset: FilterPreset;
isActive?: boolean;
isDefault?: boolean;
isGlobal?: boolean;
disabled?: boolean;
showFavorite?: boolean;
showActions?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isActive: false,
isDefault: false,
isGlobal: false,
disabled: false,
showFavorite: true,
showActions: true,
});
defineEmits<{
select: [];
"toggle-favorite": [];
rename: [];
duplicate: [];
delete: [];
}>();
// Local state
const showMenu = ref(false);
// Computed
const filterCount = computed(() => {
let count = 0;
const filters = props.preset.filters;
if (filters.search?.trim()) count++;
if (filters.categories?.length) count++;
if (filters.manufacturers?.length) count++;
if (filters.designers?.length) count++;
if (filters.parks?.length) count++;
if (filters.status?.length) count++;
if (filters.opened?.start || filters.opened?.end) count++;
if (filters.closed?.start || filters.closed?.end) count++;
if (filters.heightRange?.min !== undefined || filters.heightRange?.max !== undefined)
count++;
if (filters.speedRange?.min !== undefined || filters.speedRange?.max !== undefined)
count++;
if (
filters.durationRange?.min !== undefined ||
filters.durationRange?.max !== undefined
)
count++;
if (
filters.capacityRange?.min !== undefined ||
filters.capacityRange?.max !== undefined
)
count++;
return count;
});
// Methods
const formatLastUsed = (lastUsed: string): string => {
const date = new Date(lastUsed);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
} else if (diffDays === 1) {
return "Yesterday";
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `${weeks} ${weeks === 1 ? "week" : "weeks"} ago`;
} else {
return date.toLocaleDateString();
}
};
// Close menu when clicking outside
const handleClickOutside = () => {
showMenu.value = false;
};
// Add/remove event listener for clicking outside
if (typeof window !== "undefined") {
document.addEventListener("click", handleClickOutside);
}
</script>
<style scoped>
.preset-item {
@apply transition-all duration-200;
}
.preset-item:hover {
@apply shadow-sm;
}
</style>

View File

@@ -0,0 +1,431 @@
<template>
<div class="range-filter">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ label }}
<span v-if="required" class="text-red-500 ml-1">*</span>
</label>
<!-- Current values display -->
<div
class="flex items-center justify-between mb-3 text-sm text-gray-600 dark:text-gray-400"
>
<span>{{ unit ? `${currentMin} ${unit}` : currentMin }}</span>
<span class="text-gray-400">to</span>
<span>{{ unit ? `${currentMax} ${unit}` : currentMax }}</span>
</div>
<!-- Dual range slider -->
<div class="relative">
<div class="range-slider-container relative">
<!-- Background track -->
<div
class="range-track absolute w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full"
></div>
<!-- Active track -->
<div
class="range-track-active absolute h-2 bg-blue-500 rounded-full"
:style="activeTrackStyle"
></div>
<!-- Min range input -->
<input
ref="minInput"
type="range"
:min="min"
:max="max"
:step="step"
:value="currentMin"
@input="handleMinChange"
@change="emitChange"
class="range-input range-input-min absolute w-full h-2 bg-transparent appearance-none cursor-pointer"
:disabled="disabled"
:aria-label="`Minimum ${label.toLowerCase()}`"
/>
<!-- Max range input -->
<input
ref="maxInput"
type="range"
:min="min"
:max="max"
:step="step"
:value="currentMax"
@input="handleMaxChange"
@change="emitChange"
class="range-input range-input-max absolute w-full h-2 bg-transparent appearance-none cursor-pointer"
:disabled="disabled"
:aria-label="`Maximum ${label.toLowerCase()}`"
/>
<!-- Min thumb -->
<div
class="range-thumb range-thumb-min absolute w-5 h-5 bg-white border-2 border-blue-500 rounded-full shadow-md cursor-pointer transform -translate-y-1.5"
:style="minThumbStyle"
@mousedown="startDrag('min', $event)"
@touchstart="startDrag('min', $event)"
></div>
<!-- Max thumb -->
<div
class="range-thumb range-thumb-max absolute w-5 h-5 bg-white border-2 border-blue-500 rounded-full shadow-md cursor-pointer transform -translate-y-1.5"
:style="maxThumbStyle"
@mousedown="startDrag('max', $event)"
@touchstart="startDrag('max', $event)"
></div>
</div>
<!-- Value tooltips -->
<div
v-if="showTooltips && isDragging"
class="absolute -top-8"
:style="{ left: minThumbPosition + '%' }"
>
<div
class="bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap transform -translate-x-1/2"
>
{{ unit ? `${currentMin} ${unit}` : currentMin }}
</div>
</div>
<div
v-if="showTooltips && isDragging"
class="absolute -top-8"
:style="{ left: maxThumbPosition + '%' }"
>
<div
class="bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap transform -translate-x-1/2"
>
{{ unit ? `${currentMax} ${unit}` : currentMax }}
</div>
</div>
</div>
<!-- Manual input fields -->
<div v-if="showInputs" class="flex items-center gap-3 mt-4">
<div class="flex-1">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
Min {{ unit || "" }}
</label>
<input
type="number"
:min="min"
:max="currentMax"
:step="step"
:value="currentMin"
@input="handleMinInputChange"
@blur="emitChange"
class="w-full px-3 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
:disabled="disabled"
/>
</div>
<div class="flex-1">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
Max {{ unit || "" }}
</label>
<input
type="number"
:min="currentMin"
:max="max"
:step="step"
:value="currentMax"
@input="handleMaxInputChange"
@blur="emitChange"
class="w-full px-3 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
:disabled="disabled"
/>
</div>
</div>
<!-- Reset button -->
<button
v-if="clearable && hasChanges"
@click="reset"
class="mt-3 text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
:disabled="disabled"
>
Reset to default
</button>
<!-- Step size indicator -->
<div v-if="showStepInfo" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Step: {{ step }}{{ unit ? ` ${unit}` : "" }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
interface Props {
label: string;
min: number;
max: number;
value?: [number, number];
step?: number;
unit?: string;
required?: boolean;
disabled?: boolean;
clearable?: boolean;
showInputs?: boolean;
showTooltips?: boolean;
showStepInfo?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
step: 1,
required: false,
disabled: false,
clearable: true,
showInputs: false,
showTooltips: true,
showStepInfo: false,
});
const emit = defineEmits<{
update: [value: [number, number] | undefined];
}>();
// Local state
const currentMin = ref(props.value?.[0] ?? props.min);
const currentMax = ref(props.value?.[1] ?? props.max);
const isDragging = ref(false);
const dragType = ref<"min" | "max" | null>(null);
// Computed
const hasChanges = computed(() => {
return currentMin.value !== props.min || currentMax.value !== props.max;
});
const minThumbPosition = computed(() => {
return ((currentMin.value - props.min) / (props.max - props.min)) * 100;
});
const maxThumbPosition = computed(() => {
return ((currentMax.value - props.min) / (props.max - props.min)) * 100;
});
const minThumbStyle = computed(() => ({
left: `calc(${minThumbPosition.value}% - 10px)`,
}));
const maxThumbStyle = computed(() => ({
left: `calc(${maxThumbPosition.value}% - 10px)`,
}));
const activeTrackStyle = computed(() => ({
left: `${minThumbPosition.value}%`,
width: `${maxThumbPosition.value - minThumbPosition.value}%`,
}));
// Methods
const handleMinChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const value = Math.min(Number(target.value), currentMax.value - props.step);
currentMin.value = value;
// Ensure min doesn't exceed max
if (currentMin.value >= currentMax.value) {
currentMin.value = currentMax.value - props.step;
}
};
const handleMaxChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const value = Math.max(Number(target.value), currentMin.value + props.step);
currentMax.value = value;
// Ensure max doesn't go below min
if (currentMax.value <= currentMin.value) {
currentMax.value = currentMin.value + props.step;
}
};
const handleMinInputChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const value = Number(target.value);
if (value >= props.min && value < currentMax.value) {
currentMin.value = value;
}
};
const handleMaxInputChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const value = Number(target.value);
if (value <= props.max && value > currentMin.value) {
currentMax.value = value;
}
};
const emitChange = () => {
const hasDefaultValues =
currentMin.value === props.min && currentMax.value === props.max;
emit("update", hasDefaultValues ? undefined : [currentMin.value, currentMax.value]);
};
const reset = () => {
currentMin.value = props.min;
currentMax.value = props.max;
emitChange();
};
const startDrag = (type: "min" | "max", event: MouseEvent | TouchEvent) => {
if (props.disabled) return;
isDragging.value = true;
dragType.value = type;
event.preventDefault();
if (event instanceof MouseEvent) {
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", endDrag);
} else {
document.addEventListener("touchmove", handleDrag);
document.addEventListener("touchend", endDrag);
}
};
const handleDrag = (event: MouseEvent | TouchEvent) => {
if (!isDragging.value || !dragType.value) return;
const container = (event.target as Element).closest(".range-slider-container");
if (!container) return;
const rect = container.getBoundingClientRect();
const clientX = event instanceof MouseEvent ? event.clientX : event.touches[0].clientX;
const percentage = Math.max(
0,
Math.min(100, ((clientX - rect.left) / rect.width) * 100)
);
const value = props.min + (percentage / 100) * (props.max - props.min);
const steppedValue = Math.round(value / props.step) * props.step;
if (dragType.value === "min") {
currentMin.value = Math.max(
props.min,
Math.min(steppedValue, currentMax.value - props.step)
);
} else {
currentMax.value = Math.min(
props.max,
Math.max(steppedValue, currentMin.value + props.step)
);
}
};
const endDrag = () => {
isDragging.value = false;
dragType.value = null;
emitChange();
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchmove", handleDrag);
document.removeEventListener("touchend", endDrag);
};
// Watch for prop changes
onMounted(() => {
if (props.value) {
currentMin.value = props.value[0];
currentMax.value = props.value[1];
}
});
onUnmounted(() => {
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchmove", handleDrag);
document.removeEventListener("touchend", endDrag);
});
</script>
<style scoped>
.range-slider-container {
height: 2rem;
margin: 0.5rem 0;
}
.range-input {
z-index: 1;
}
.range-input::-webkit-slider-thumb {
appearance: none;
width: 20px;
height: 20px;
background: transparent;
cursor: pointer;
pointer-events: all;
}
.range-input::-moz-range-thumb {
width: 20px;
height: 20px;
background: transparent;
cursor: pointer;
border: none;
pointer-events: all;
}
.range-input-min {
z-index: 2;
}
.range-input-max {
z-index: 1;
}
.range-thumb {
z-index: 3;
transition: transform 0.1s ease;
pointer-events: all;
}
.range-thumb:hover {
transform: translateY(-1.5px) scale(1.1);
}
.range-thumb:active {
transform: translateY(-1.5px) scale(1.2);
}
.range-input:disabled + .range-thumb {
opacity: 0.5;
cursor: not-allowed;
}
.range-track {
top: 50%;
transform: translateY(-50%);
}
.range-track-active {
top: 50%;
transform: translateY(-50%);
z-index: 1;
}
/* Custom focus styles */
.range-input:focus {
outline: none;
}
.range-input:focus + .range-thumb {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
/* Dark mode adjustments */
.dark .range-thumb {
border-color: #3b82f6;
background: #1f2937;
}
.dark .range-track-active {
background: #3b82f6;
}
</style>

View File

@@ -0,0 +1,484 @@
<template>
<div class="ride-filter-sidebar">
<!-- Filter Header -->
<div class="filter-header">
<div
class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700"
>
<div class="flex items-center gap-3">
<Icon name="filter" class="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Filters</h2>
<span
v-if="activeFiltersCount > 0"
class="px-2 py-1 text-xs font-medium text-white bg-blue-600 rounded-full"
>
{{ activeFiltersCount }}
</span>
</div>
<!-- Mobile close button -->
<button
v-if="isMobile"
@click="closeFilterForm"
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 lg:hidden"
aria-label="Close filters"
>
<Icon name="x" class="w-5 h-5" />
</button>
</div>
<!-- Filter Actions -->
<div
class="flex items-center gap-2 p-4 border-b border-gray-200 dark:border-gray-700"
>
<button
@click="applyFilters"
:disabled="!hasUnsavedChanges"
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
Apply Filters
</button>
<button
@click="clearAllFilters"
:disabled="!hasActiveFilters"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Clear All
</button>
<button
@click="expandAllSections"
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Expand all sections"
>
<Icon name="chevron-down" class="w-4 h-4" />
</button>
</div>
</div>
<!-- Filter Sections -->
<div class="filter-sections flex-1 overflow-y-auto">
<!-- Search Section -->
<FilterSection
id="search"
title="Search"
:is-expanded="formState.expandedSections.search"
@toggle="toggleSection('search')"
>
<SearchFilter />
</FilterSection>
<!-- Basic Filters Section -->
<FilterSection
id="basic"
title="Basic Filters"
:is-expanded="formState.expandedSections.basic"
@toggle="toggleSection('basic')"
>
<div class="space-y-4">
<SelectFilter
id="category"
label="Category"
:options="filterOptions?.categories || []"
:value="filters.category"
@update="updateFilter('category', $event)"
multiple
/>
<SelectFilter
id="status"
label="Status"
:options="filterOptions?.statuses || []"
:value="filters.status"
@update="updateFilter('status', $event)"
multiple
/>
</div>
</FilterSection>
<!-- Manufacturer Section -->
<FilterSection
id="manufacturer"
title="Manufacturer & Design"
:is-expanded="formState.expandedSections.manufacturer"
@toggle="toggleSection('manufacturer')"
>
<div class="space-y-4">
<SearchableSelect
id="manufacturer"
label="Manufacturer"
:options="filterOptions?.manufacturers || []"
:value="filters.manufacturer"
@update="updateFilter('manufacturer', $event)"
multiple
searchable
/>
<SearchableSelect
id="designer"
label="Designer"
:options="filterOptions?.designers || []"
:value="filters.designer"
@update="updateFilter('designer', $event)"
multiple
searchable
/>
</div>
</FilterSection>
<!-- Specifications Section -->
<FilterSection
id="specifications"
title="Specifications"
:is-expanded="formState.expandedSections.specifications"
@toggle="toggleSection('specifications')"
>
<div class="space-y-6">
<RangeFilter
id="height"
label="Height"
unit="m"
:min-value="filters.height_min"
:max-value="filters.height_max"
:min-limit="0"
:max-limit="200"
@update-min="updateFilter('height_min', $event)"
@update-max="updateFilter('height_max', $event)"
/>
<RangeFilter
id="speed"
label="Speed"
unit="km/h"
:min-value="filters.speed_min"
:max-value="filters.speed_max"
:min-limit="0"
:max-limit="250"
@update-min="updateFilter('speed_min', $event)"
@update-max="updateFilter('speed_max', $event)"
/>
<RangeFilter
id="length"
label="Length"
unit="m"
:min-value="filters.length_min"
:max-value="filters.length_max"
:min-limit="0"
:max-limit="3000"
@update-min="updateFilter('length_min', $event)"
@update-max="updateFilter('length_max', $event)"
/>
<RangeFilter
id="capacity"
label="Capacity"
unit="people"
:min-value="filters.capacity_min"
:max-value="filters.capacity_max"
:min-limit="1"
:max-limit="50"
@update-min="updateFilter('capacity_min', $event)"
@update-max="updateFilter('capacity_max', $event)"
/>
<RangeFilter
id="duration"
label="Duration"
unit="seconds"
:min-value="filters.duration_min"
:max-value="filters.duration_max"
:min-limit="30"
:max-limit="600"
@update-min="updateFilter('duration_min', $event)"
@update-max="updateFilter('duration_max', $event)"
/>
<RangeFilter
id="inversions"
label="Inversions"
unit=""
:min-value="filters.inversions_min"
:max-value="filters.inversions_max"
:min-limit="0"
:max-limit="20"
@update-min="updateFilter('inversions_min', $event)"
@update-max="updateFilter('inversions_max', $event)"
/>
</div>
</FilterSection>
<!-- Dates Section -->
<FilterSection
id="dates"
title="Opening & Closing Dates"
:is-expanded="formState.expandedSections.dates"
@toggle="toggleSection('dates')"
>
<div class="space-y-4">
<DateRangeFilter
id="opening_date"
label="Opening Date"
:from-value="filters.opening_date_from"
:to-value="filters.opening_date_to"
@update-from="updateFilter('opening_date_from', $event)"
@update-to="updateFilter('opening_date_to', $event)"
/>
<DateRangeFilter
id="closing_date"
label="Closing Date"
:from-value="filters.closing_date_from"
:to-value="filters.closing_date_to"
@update-from="updateFilter('closing_date_from', $event)"
@update-to="updateFilter('closing_date_to', $event)"
/>
</div>
</FilterSection>
<!-- Location Section -->
<FilterSection
id="location"
title="Location"
:is-expanded="formState.expandedSections.location"
@toggle="toggleSection('location')"
>
<div class="space-y-4">
<SearchableSelect
id="park"
label="Park"
:options="filterOptions?.parks || []"
:value="filters.park"
@update="updateFilter('park', $event)"
multiple
searchable
/>
<SelectFilter
id="country"
label="Country"
:options="filterOptions?.countries || []"
:value="filters.country"
@update="updateFilter('country', $event)"
multiple
/>
<SelectFilter
id="region"
label="Region"
:options="filterOptions?.regions || []"
:value="filters.region"
@update="updateFilter('region', $event)"
multiple
/>
</div>
</FilterSection>
<!-- Advanced Section -->
<FilterSection
id="advanced"
title="Advanced Options"
:is-expanded="formState.expandedSections.advanced"
@toggle="toggleSection('advanced')"
>
<div class="space-y-4">
<SelectFilter
id="ordering"
label="Sort By"
:options="sortingOptions"
:value="filters.ordering"
@update="updateFilter('ordering', $event)"
/>
<SelectFilter
id="page_size"
label="Results Per Page"
:options="pageSizeOptions"
:value="filters.page_size"
@update="updateFilter('page_size', $event)"
/>
</div>
</FilterSection>
</div>
<!-- Active Filters Display -->
<div
v-if="hasActiveFilters"
class="active-filters border-t border-gray-200 dark:border-gray-700 p-4"
>
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">
Active Filters ({{ activeFiltersCount }})
</h3>
<div class="flex flex-wrap gap-2">
<ActiveFilterChip
v-for="filter in activeFiltersList"
:key="`${filter.key}-${filter.value}`"
:filter="filter"
@remove="clearFilter(filter.key)"
/>
</div>
</div>
<!-- Filter Presets -->
<div
v-if="savedPresets.length > 0"
class="filter-presets border-t border-gray-200 dark:border-gray-700 p-4"
>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Saved Presets</h3>
<button
@click="showSavePresetDialog = true"
class="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
Save Current
</button>
</div>
<div class="space-y-2">
<PresetItem
v-for="preset in savedPresets"
:key="preset.id"
:preset="preset"
:is-active="currentPreset === preset.id"
@load="loadPreset(preset.id)"
@delete="deletePreset(preset.id)"
/>
</div>
</div>
</div>
<!-- Save Preset Dialog -->
<SavePresetDialog
v-if="showSavePresetDialog"
@save="handleSavePreset"
@cancel="showSavePresetDialog = false"
/>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRideFilteringStore } from "@/stores/rideFiltering";
import { storeToRefs } from "pinia";
// Components
import FilterSection from "./FilterSection.vue";
import SearchFilter from "./SearchFilter.vue";
import SelectFilter from "./SelectFilter.vue";
import SearchableSelect from "./SearchableSelect.vue";
import RangeFilter from "./RangeFilter.vue";
import DateRangeFilter from "./DateRangeFilter.vue";
import ActiveFilterChip from "./ActiveFilterChip.vue";
import PresetItem from "./PresetItem.vue";
import SavePresetDialog from "./SavePresetDialog.vue";
import Icon from "@/components/ui/Icon.vue";
// Store
const store = useRideFilteringStore();
const {
filters,
filterOptions,
formState,
hasActiveFilters,
activeFiltersCount,
activeFiltersList,
hasUnsavedChanges,
savedPresets,
currentPreset,
} = storeToRefs(store);
const {
updateFilter,
clearFilter,
clearAllFilters,
applyFilters,
toggleSection,
expandAllSections,
collapseAllSections,
closeFilterForm,
savePreset,
loadPreset,
deletePreset,
} = store;
// Local state
const showSavePresetDialog = ref(false);
const isMobile = ref(false);
// Computed
const sortingOptions = computed(() => [
{ value: "name", label: "Name (A-Z)" },
{ value: "-name", label: "Name (Z-A)" },
{ value: "opening_date", label: "Oldest First" },
{ value: "-opening_date", label: "Newest First" },
{ value: "-height", label: "Tallest First" },
{ value: "height", label: "Shortest First" },
{ value: "-speed", label: "Fastest First" },
{ value: "speed", label: "Slowest First" },
{ value: "-rating", label: "Highest Rated" },
{ value: "rating", label: "Lowest Rated" },
]);
const pageSizeOptions = computed(() => [
{ value: 10, label: "10 per page" },
{ value: 25, label: "25 per page" },
{ value: 50, label: "50 per page" },
{ value: 100, label: "100 per page" },
]);
// Methods
const handleSavePreset = (name: string) => {
savePreset(name);
showSavePresetDialog.value = false;
};
const checkMobile = () => {
isMobile.value = window.innerWidth < 1024;
};
// Lifecycle
onMounted(() => {
checkMobile();
window.addEventListener("resize", checkMobile);
});
</script>
<style scoped>
.ride-filter-sidebar {
@apply flex flex-col h-full bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700;
}
.filter-header {
@apply flex-shrink-0;
}
.filter-sections {
@apply flex-1 overflow-y-auto;
}
.active-filters {
@apply flex-shrink-0;
}
.filter-presets {
@apply flex-shrink-0;
}
/* Custom scrollbar for filter sections */
.filter-sections::-webkit-scrollbar {
@apply w-2;
}
.filter-sections::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-700;
}
.filter-sections::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
}
.filter-sections::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
</style>

View File

@@ -0,0 +1,401 @@
<template>
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click="$emit('close')"
>
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4"
@click.stop
>
<!-- Header -->
<div
class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700"
>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ editMode ? "Edit Preset" : "Save Filter Preset" }}
</h3>
<button
@click="$emit('close')"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close dialog"
>
<Icon name="x" class="w-5 h-5" />
</button>
</div>
<!-- Form -->
<form @submit.prevent="handleSave" class="p-6">
<!-- Name field -->
<div class="mb-4">
<label
for="preset-name"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Preset Name *
</label>
<input
id="preset-name"
v-model="formData.name"
type="text"
required
maxlength="50"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white transition-colors"
:class="{
'border-red-300 dark:border-red-600': errors.name,
}"
placeholder="Enter preset name..."
@blur="validateName"
/>
<p v-if="errors.name" class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ errors.name }}
</p>
</div>
<!-- Description field -->
<div class="mb-4">
<label
for="preset-description"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Description
</label>
<textarea
id="preset-description"
v-model="formData.description"
rows="3"
maxlength="200"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white transition-colors resize-none"
placeholder="Optional description for this preset..."
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ formData.description?.length || 0 }}/200 characters
</p>
</div>
<!-- Scope selection -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Preset Scope
</label>
<div class="space-y-2">
<label class="flex items-center">
<input
v-model="formData.scope"
type="radio"
value="personal"
class="mr-2 text-blue-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
Personal (only visible to me)
</span>
</label>
<label v-if="allowGlobal" class="flex items-center">
<input
v-model="formData.scope"
type="radio"
value="global"
class="mr-2 text-blue-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
Global (visible to all users)
</span>
</label>
</div>
</div>
<!-- Make default checkbox -->
<div class="mb-6">
<label class="flex items-center">
<input
v-model="formData.isDefault"
type="checkbox"
class="mr-2 text-blue-600 rounded"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
Set as my default preset
</span>
</label>
</div>
<!-- Filter summary -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Filters to Save
</h4>
<div class="space-y-1">
<p
v-if="filterSummary.length === 0"
class="text-sm text-gray-500 dark:text-gray-400"
>
No active filters
</p>
<p
v-for="filter in filterSummary"
:key="filter.key"
class="text-sm text-gray-600 dark:text-gray-400"
>
{{ filter.label }}: {{ filter.value }}
</p>
</div>
</div>
<!-- Buttons -->
<div class="flex gap-3 justify-end">
<button
type="button"
@click="$emit('close')"
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
:disabled="!isValid || isLoading"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white rounded-lg transition-colors flex items-center gap-2"
>
<Icon v-if="isLoading" name="loading" class="w-4 h-4 animate-spin" />
{{ editMode ? "Update" : "Save" }} Preset
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import Icon from "@/components/ui/Icon.vue";
import type { FilterState, FilterPreset } from "@/types/filters";
interface Props {
isOpen: boolean;
filters: FilterState;
editMode?: boolean;
existingPreset?: FilterPreset;
allowGlobal?: boolean;
existingNames?: string[];
}
const props = withDefaults(defineProps<Props>(), {
editMode: false,
allowGlobal: false,
existingNames: () => [],
});
defineEmits<{
close: [];
save: [preset: Partial<FilterPreset>];
}>();
// Form data
const formData = ref({
name: "",
description: "",
scope: "personal" as "personal" | "global",
isDefault: false,
});
// Form state
const errors = ref({
name: "",
});
const isLoading = ref(false);
// Computed
const isValid = computed(() => {
return formData.value.name.trim().length > 0 && !errors.value.name;
});
const filterSummary = computed(() => {
const summary: Array<{ key: string; label: string; value: string }> = [];
const filters = props.filters;
if (filters.search?.trim()) {
summary.push({
key: "search",
label: "Search",
value: filters.search,
});
}
if (filters.categories?.length) {
summary.push({
key: "categories",
label: "Categories",
value: filters.categories.join(", "),
});
}
if (filters.manufacturers?.length) {
summary.push({
key: "manufacturers",
label: "Manufacturers",
value: filters.manufacturers.join(", "),
});
}
if (filters.designers?.length) {
summary.push({
key: "designers",
label: "Designers",
value: filters.designers.join(", "),
});
}
if (filters.parks?.length) {
summary.push({
key: "parks",
label: "Parks",
value: filters.parks.join(", "),
});
}
if (filters.status?.length) {
summary.push({
key: "status",
label: "Status",
value: filters.status.join(", "),
});
}
if (filters.opened?.start || filters.opened?.end) {
const start = filters.opened.start || "Any";
const end = filters.opened.end || "Any";
summary.push({
key: "opened",
label: "Opened",
value: `${start} to ${end}`,
});
}
if (filters.closed?.start || filters.closed?.end) {
const start = filters.closed.start || "Any";
const end = filters.closed.end || "Any";
summary.push({
key: "closed",
label: "Closed",
value: `${start} to ${end}`,
});
}
// Range filters
const ranges = [
{ key: "heightRange", label: "Height", data: filters.heightRange, unit: "m" },
{ key: "speedRange", label: "Speed", data: filters.speedRange, unit: "km/h" },
{ key: "durationRange", label: "Duration", data: filters.durationRange, unit: "min" },
{ key: "capacityRange", label: "Capacity", data: filters.capacityRange, unit: "" },
];
ranges.forEach(({ key, label, data, unit }) => {
if (data?.min !== undefined || data?.max !== undefined) {
const min = data.min ?? "Any";
const max = data.max ?? "Any";
summary.push({
key,
label,
value: `${min} - ${max}${unit ? " " + unit : ""}`,
});
}
});
return summary;
});
// Methods
const validateName = () => {
const name = formData.value.name.trim();
errors.value.name = "";
if (name.length === 0) {
errors.value.name = "Preset name is required";
} else if (name.length < 2) {
errors.value.name = "Preset name must be at least 2 characters";
} else if (name.length > 50) {
errors.value.name = "Preset name must be 50 characters or less";
} else if (
props.existingNames.includes(name.toLowerCase()) &&
(!props.editMode || name.toLowerCase() !== props.existingPreset?.name.toLowerCase())
) {
errors.value.name = "A preset with this name already exists";
}
};
const handleSave = async () => {
validateName();
if (!isValid.value) return;
isLoading.value = true;
try {
const preset: Partial<FilterPreset> = {
name: formData.value.name.trim(),
description: formData.value.description?.trim() || undefined,
filters: props.filters,
scope: formData.value.scope,
isDefault: formData.value.isDefault,
lastUsed: new Date().toISOString(),
};
if (props.editMode && props.existingPreset) {
preset.id = props.existingPreset.id;
}
// Emit save event
await new Promise((resolve) => {
const emit = defineEmits<{
save: [preset: Partial<FilterPreset>];
}>();
emit("save", preset);
setTimeout(resolve, 100); // Small delay to simulate async operation
});
} finally {
isLoading.value = false;
}
};
// Watchers
watch(
() => props.isOpen,
async (isOpen) => {
if (isOpen) {
if (props.editMode && props.existingPreset) {
formData.value = {
name: props.existingPreset.name,
description: props.existingPreset.description || "",
scope: props.existingPreset.scope || "personal",
isDefault: props.existingPreset.isDefault || false,
};
} else {
formData.value = {
name: "",
description: "",
scope: "personal",
isDefault: false,
};
}
errors.value.name = "";
// Focus the name input
await nextTick();
const nameInput = document.getElementById("preset-name");
if (nameInput) {
nameInput.focus();
}
}
},
{ immediate: true }
);
watch(
() => formData.value.name,
() => {
if (errors.value.name) {
validateName();
}
}
);
</script>
<style scoped>
/* Add any custom styles if needed */
</style>

View File

@@ -0,0 +1,339 @@
<template>
<div class="search-filter">
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="search" class="w-4 h-4 text-gray-400" />
</div>
<input
ref="searchInput"
v-model="searchQuery"
type="text"
placeholder="Search rides, parks, manufacturers..."
class="search-input block w-full pl-10 pr-10 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white text-sm"
@input="handleSearchInput"
@focus="showSuggestions = true"
@blur="handleBlur"
@keydown="handleKeydown"
:aria-expanded="showSuggestions && suggestions.length > 0"
aria-haspopup="listbox"
role="combobox"
autocomplete="off"
/>
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
<button
v-if="searchQuery"
@click="clearSearch"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="Clear search"
>
<Icon name="x" class="w-4 h-4" />
</button>
</div>
</div>
<!-- Search Suggestions -->
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div
v-if="showSuggestions && suggestions.length > 0"
class="suggestions-dropdown absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-auto"
role="listbox"
>
<div
v-for="(suggestion, index) in suggestions"
:key="`${suggestion.type}-${suggestion.value}`"
@click="selectSuggestion(suggestion)"
@mouseenter="highlightedIndex = index"
class="suggestion-item flex items-center px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{
'bg-blue-50 dark:bg-blue-900': highlightedIndex === index,
}"
role="option"
:aria-selected="highlightedIndex === index"
>
<Icon
:name="getSuggestionIcon(suggestion.type)"
class="w-4 h-4 mr-3 text-gray-500 dark:text-gray-400"
/>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">
{{ suggestion.label }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 capitalize">
{{ suggestion.type }}
</div>
</div>
<div v-if="suggestion.count" class="text-xs text-gray-400 ml-2">
{{ suggestion.count }}
</div>
</div>
<div v-if="isLoadingSuggestions" class="flex items-center justify-center py-3">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">Loading...</span>
</div>
</div>
</Transition>
<!-- Quick Search Filters -->
<div v-if="quickFilters.length > 0" class="mt-3">
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Quick Filters
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="filter in quickFilters"
:key="filter.value"
@click="applyQuickFilter(filter)"
class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors"
>
<Icon :name="filter.icon" class="w-3 h-3 mr-1" />
{{ filter.label }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue";
import { useRideFilteringStore } from "@/stores/rideFiltering";
import { useRideFiltering } from "@/composables/useRideFiltering";
import { storeToRefs } from "pinia";
import { debounce } from "lodash-es";
import Icon from "@/components/ui/Icon.vue";
// Store
const store = useRideFilteringStore();
const {
searchQuery,
searchSuggestions,
showSuggestions: showStoreSuggestions,
} = storeToRefs(store);
const { setSearchQuery, showSearchSuggestions, hideSearchSuggestions } = store;
// Composable
const { getSearchSuggestions } = useRideFiltering();
// Local state
const searchInput = ref<HTMLInputElement>();
const highlightedIndex = ref(-1);
const showSuggestions = ref(false);
const isLoadingSuggestions = ref(false);
// Computed
const suggestions = computed(() => searchSuggestions.value || []);
const quickFilters = computed(() => [
{
value: "operating",
label: "Operating",
icon: "play",
filter: { status: ["operating"] },
},
{
value: "roller_coaster",
label: "Roller Coasters",
icon: "trending-up",
filter: { category: ["roller_coaster"] },
},
{
value: "water_ride",
label: "Water Rides",
icon: "droplet",
filter: { category: ["water_ride"] },
},
{
value: "family",
label: "Family Friendly",
icon: "users",
filter: { category: ["family"] },
},
]);
// Methods
const handleSearchInput = debounce(async (event: Event) => {
const target = event.target as HTMLInputElement;
const query = target.value;
setSearchQuery(query);
if (query.length >= 2) {
isLoadingSuggestions.value = true;
showSuggestions.value = true;
try {
await getSearchSuggestions(query);
} catch (error) {
console.error("Failed to load search suggestions:", error);
} finally {
isLoadingSuggestions.value = false;
}
} else {
showSuggestions.value = false;
highlightedIndex.value = -1;
}
}, 300);
const handleBlur = () => {
// Delay hiding suggestions to allow for clicks
setTimeout(() => {
showSuggestions.value = false;
highlightedIndex.value = -1;
}, 150);
};
const handleKeydown = async (event: KeyboardEvent) => {
if (!showSuggestions.value || suggestions.value.length === 0) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
highlightedIndex.value = Math.min(
highlightedIndex.value + 1,
suggestions.value.length - 1
);
break;
case "ArrowUp":
event.preventDefault();
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1);
break;
case "Enter":
event.preventDefault();
if (highlightedIndex.value >= 0) {
selectSuggestion(suggestions.value[highlightedIndex.value]);
}
break;
case "Escape":
showSuggestions.value = false;
highlightedIndex.value = -1;
searchInput.value?.blur();
break;
}
};
const selectSuggestion = (suggestion: any) => {
setSearchQuery(suggestion.label);
showSuggestions.value = false;
highlightedIndex.value = -1;
// Apply additional filters based on suggestion type
if (suggestion.filters) {
Object.entries(suggestion.filters).forEach(([key, value]) => {
store.updateFilter(key as any, value);
});
}
};
const clearSearch = () => {
setSearchQuery("");
showSuggestions.value = false;
highlightedIndex.value = -1;
searchInput.value?.focus();
};
const applyQuickFilter = (filter: any) => {
Object.entries(filter.filter).forEach(([key, value]) => {
store.updateFilter(key as any, value);
});
};
const getSuggestionIcon = (type: string): string => {
const icons: Record<string, string> = {
ride: "activity",
park: "map-pin",
manufacturer: "building",
designer: "user",
category: "tag",
location: "globe",
};
return icons[type] || "search";
};
// Lifecycle
onMounted(() => {
// Focus search input on mount if no active filters
if (!store.hasActiveFilters) {
nextTick(() => {
searchInput.value?.focus();
});
}
});
// Handle clicks outside
const handleClickOutside = (event: Event) => {
if (searchInput.value && !searchInput.value.contains(event.target as Node)) {
showSuggestions.value = false;
highlightedIndex.value = -1;
}
};
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
</script>
<style scoped>
.search-filter {
@apply relative;
}
.search-input {
@apply transition-colors duration-200;
}
.search-input:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
.suggestions-dropdown {
@apply border shadow-lg;
}
.suggestion-item {
@apply transition-colors duration-150;
}
.suggestion-item:first-child {
@apply rounded-t-md;
}
.suggestion-item:last-child {
@apply rounded-b-md;
}
/* Custom scrollbar for suggestions */
.suggestions-dropdown::-webkit-scrollbar {
@apply w-2;
}
.suggestions-dropdown::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-700;
}
.suggestions-dropdown::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
}
.suggestions-dropdown::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
</style>

View File

@@ -0,0 +1,484 @@
<template>
<div class="searchable-select">
<label
:for="id"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{{ label }}
<span v-if="required" class="text-red-500 ml-1">*</span>
</label>
<div class="relative">
<!-- Search input -->
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="search" class="w-4 h-4 text-gray-400" />
</div>
<input
ref="searchInput"
v-model="searchQuery"
@input="handleSearchInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown"
type="text"
:id="id"
:placeholder="searchPlaceholder"
class="search-input block w-full pl-10 pr-10 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"
:class="{
'text-gray-400 dark:text-gray-500': !hasSelection && !searchQuery,
}"
:required="required"
:disabled="disabled"
autocomplete="off"
:aria-expanded="isOpen"
:aria-haspopup="true"
role="combobox"
/>
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
<button
v-if="searchQuery || hasSelection"
@click="clearAll"
type="button"
class="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
:disabled="disabled"
>
<Icon name="x" class="w-4 h-4" />
</button>
<Icon
v-else
name="chevron-down"
class="w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ml-1"
:class="{ 'rotate-180': isOpen }"
/>
</div>
</div>
<!-- Selected items display -->
<div v-if="hasSelection && !isOpen" class="mt-2 flex flex-wrap gap-1">
<span
v-for="selectedOption in selectedOptions"
:key="selectedOption.value"
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"
>
{{ selectedOption.label }}
<button
@click="removeOption(selectedOption.value)"
class="ml-1 text-blue-600 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-100"
:disabled="disabled"
>
<Icon name="x" class="w-3 h-3" />
</button>
</span>
</div>
<!-- Dropdown -->
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div
v-if="isOpen"
class="dropdown-menu absolute z-20 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-auto"
>
<!-- Loading state -->
<div v-if="isLoading" class="flex items-center justify-center py-4">
<div
class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"
></div>
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">Loading...</span>
</div>
<!-- Options -->
<div v-else-if="filteredOptions.length > 0">
<div
v-for="(option, index) in filteredOptions"
:key="option.value"
@click="toggleOption(option.value)"
@mouseenter="highlightedIndex = index"
class="dropdown-item flex items-center px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{
'bg-blue-50 dark:bg-blue-900': highlightedIndex === index,
'bg-green-50 dark:bg-green-900': isSelected(option.value),
}"
role="option"
:aria-selected="isSelected(option.value)"
>
<div class="flex items-center mr-3">
<input
type="checkbox"
:checked="isSelected(option.value)"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
readonly
/>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">
<span v-html="highlightSearchTerm(option.label)"></span>
</div>
<div
v-if="option.description"
class="text-xs text-gray-500 dark:text-gray-400 truncate"
>
{{ option.description }}
</div>
</div>
<div v-if="option.count !== undefined" class="text-xs text-gray-400 ml-2">
{{ option.count }}
</div>
</div>
</div>
<!-- No results -->
<div
v-else-if="searchQuery"
class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400 text-center"
>
No results found for "{{ searchQuery }}"
<button
v-if="allowCreate"
@click="createOption"
class="block mt-2 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
>
Create "{{ searchQuery }}"
</button>
</div>
<!-- No options message -->
<div
v-else
class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400 text-center"
>
{{ noOptionsMessage }}
</div>
</div>
</Transition>
</div>
<!-- Selected count -->
<div v-if="hasSelection" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ selectedCount }} selected
<button
v-if="clearable"
@click="clearSelection"
class="ml-2 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
:disabled="disabled"
>
Clear all
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
import { debounce } from "lodash-es";
import Icon from "@/components/ui/Icon.vue";
interface Option {
value: string | number;
label: string;
description?: string;
count?: number;
disabled?: boolean;
}
interface Props {
id: string;
label: string;
value?: (string | number)[];
options: Option[];
searchPlaceholder?: string;
noOptionsMessage?: string;
required?: boolean;
disabled?: boolean;
clearable?: boolean;
allowCreate?: boolean;
isLoading?: boolean;
minSearchLength?: number;
}
const props = withDefaults(defineProps<Props>(), {
searchPlaceholder: "Search options...",
noOptionsMessage: "No options available",
required: false,
disabled: false,
clearable: true,
allowCreate: false,
isLoading: false,
minSearchLength: 0,
});
const emit = defineEmits<{
update: [value: (string | number)[] | undefined];
search: [query: string];
create: [value: string];
}>();
// Local state
const searchInput = ref<HTMLInputElement>();
const searchQuery = ref("");
const isOpen = ref(false);
const highlightedIndex = ref(-1);
// Computed
const selectedValues = computed(() => {
return Array.isArray(props.value) ? props.value : [];
});
const selectedOptions = computed(() => {
return props.options.filter((option) => selectedValues.value.includes(option.value));
});
const hasSelection = computed(() => {
return selectedValues.value.length > 0;
});
const selectedCount = computed(() => {
return selectedValues.value.length;
});
const filteredOptions = computed(() => {
if (!searchQuery.value || searchQuery.value.length < props.minSearchLength) {
return props.options;
}
const query = searchQuery.value.toLowerCase();
return props.options.filter(
(option) =>
option.label.toLowerCase().includes(query) ||
(option.description && option.description.toLowerCase().includes(query))
);
});
// Methods
const handleSearchInput = debounce((event: Event) => {
const target = event.target as HTMLInputElement;
const query = target.value;
searchQuery.value = query;
if (query.length >= props.minSearchLength) {
emit("search", query);
}
if (!isOpen.value && query) {
isOpen.value = true;
}
}, 300);
const handleFocus = () => {
if (!props.disabled) {
isOpen.value = true;
highlightedIndex.value = -1;
}
};
const handleBlur = () => {
// Delay hiding to allow for clicks
setTimeout(() => {
if (!searchInput.value?.matches(":focus")) {
isOpen.value = false;
highlightedIndex.value = -1;
}
}, 150);
};
const handleKeydown = (event: KeyboardEvent) => {
if (!isOpen.value) {
if (event.key === "ArrowDown" || event.key === "Enter") {
event.preventDefault();
isOpen.value = true;
return;
}
return;
}
switch (event.key) {
case "ArrowDown":
event.preventDefault();
highlightedIndex.value = Math.min(
highlightedIndex.value + 1,
filteredOptions.value.length - 1
);
break;
case "ArrowUp":
event.preventDefault();
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1);
break;
case "Enter":
event.preventDefault();
if (highlightedIndex.value >= 0 && filteredOptions.value[highlightedIndex.value]) {
toggleOption(filteredOptions.value[highlightedIndex.value].value);
} else if (props.allowCreate && searchQuery.value) {
createOption();
}
break;
case "Escape":
isOpen.value = false;
highlightedIndex.value = -1;
searchInput.value?.blur();
break;
case "Backspace":
if (!searchQuery.value && hasSelection.value) {
// Remove last selected item when backspacing with empty search
const lastSelected = selectedValues.value[selectedValues.value.length - 1];
removeOption(lastSelected);
}
break;
}
};
const toggleOption = (optionValue: string | number) => {
if (props.disabled) return;
const currentValues = [...selectedValues.value];
const index = currentValues.indexOf(optionValue);
if (index >= 0) {
currentValues.splice(index, 1);
} else {
currentValues.push(optionValue);
}
emit("update", currentValues.length > 0 ? currentValues : undefined);
// Clear search after selection
searchQuery.value = "";
nextTick(() => {
searchInput.value?.focus();
});
};
const removeOption = (optionValue: string | number) => {
if (props.disabled) return;
const currentValues = [...selectedValues.value];
const index = currentValues.indexOf(optionValue);
if (index >= 0) {
currentValues.splice(index, 1);
emit("update", currentValues.length > 0 ? currentValues : undefined);
}
};
const isSelected = (optionValue: string | number): boolean => {
return selectedValues.value.includes(optionValue);
};
const clearSelection = () => {
if (!props.disabled) {
emit("update", undefined);
}
};
const clearAll = () => {
searchQuery.value = "";
if (hasSelection.value) {
clearSelection();
}
searchInput.value?.focus();
};
const createOption = () => {
if (props.allowCreate && searchQuery.value.trim()) {
emit("create", searchQuery.value.trim());
searchQuery.value = "";
}
};
const highlightSearchTerm = (text: string): string => {
if (!searchQuery.value) return text;
const regex = new RegExp(`(${searchQuery.value})`, "gi");
return text.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800">$1</mark>');
};
// Handle clicks outside
const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement;
if (!target.closest(".searchable-select")) {
isOpen.value = false;
highlightedIndex.value = -1;
}
};
// Watch for options changes to reset highlighted index
watch(
() => filteredOptions.value.length,
() => {
highlightedIndex.value = -1;
}
);
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
</script>
<style scoped>
.search-input {
@apply transition-colors duration-200;
}
.search-input:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
.search-input:disabled {
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
}
.dropdown-menu {
@apply border shadow-lg;
}
.dropdown-item {
@apply transition-colors duration-150;
}
.dropdown-item:first-child {
@apply rounded-t-md;
}
.dropdown-item:last-child {
@apply rounded-b-md;
}
/* Custom scrollbar for dropdown */
.dropdown-menu::-webkit-scrollbar {
@apply w-2;
}
.dropdown-menu::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-700;
}
.dropdown-menu::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
}
.dropdown-menu::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
/* Highlight search terms */
:deep(mark) {
@apply bg-yellow-200 dark:bg-yellow-800 px-0;
}
</style>

View File

@@ -0,0 +1,356 @@
<template>
<div class="select-filter">
<label
:for="id"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{{ label }}
<span v-if="required" class="text-red-500 ml-1">*</span>
</label>
<div class="relative">
<select
v-if="!multiple"
:id="id"
:value="value"
@change="handleSingleChange"
class="select-input block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"
:class="{
'text-gray-400 dark:text-gray-500': !value,
}"
:required="required"
:disabled="disabled"
>
<option value="" class="text-gray-500">
{{ placeholder || `Select ${label.toLowerCase()}...` }}
</option>
<option
v-for="option in normalizedOptions"
:key="option.value"
:value="option.value"
class="text-gray-900 dark:text-white"
>
{{ option.label }}
</option>
</select>
<!-- Multiple select with custom UI -->
<div v-else class="multi-select-container">
<button
type="button"
@click="toggleDropdown"
class="select-trigger flex items-center justify-between w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"
:class="{
'text-gray-400 dark:text-gray-500': !hasSelection,
}"
:disabled="disabled"
:aria-expanded="isOpen"
:aria-haspopup="true"
>
<span class="flex-1 text-left truncate">
<span v-if="!hasSelection">
{{ placeholder || `Select ${label.toLowerCase()}...` }}
</span>
<span v-else class="flex flex-wrap gap-1">
<span
v-for="selectedOption in selectedOptions"
:key="selectedOption.value"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
{{ selectedOption.label }}
<button
@click.stop="removeOption(selectedOption.value)"
class="ml-1 text-blue-600 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-100"
:disabled="disabled"
>
<Icon name="x" class="w-3 h-3" />
</button>
</span>
</span>
</span>
<Icon
name="chevron-down"
class="w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform"
:class="{ 'rotate-180': isOpen }"
/>
</button>
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div
v-if="isOpen"
class="dropdown-menu absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-auto"
>
<div
v-for="option in normalizedOptions"
:key="option.value"
@click="toggleOption(option.value)"
class="dropdown-item flex items-center px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{
'bg-blue-50 dark:bg-blue-900': isSelected(option.value),
}"
>
<div class="flex items-center mr-3">
<input
type="checkbox"
:checked="isSelected(option.value)"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
readonly
/>
</div>
<span class="flex-1 text-sm text-gray-900 dark:text-white">
{{ option.label }}
</span>
<span
v-if="option.count !== undefined"
class="text-xs text-gray-500 dark:text-gray-400"
>
{{ option.count }}
</span>
</div>
<div
v-if="normalizedOptions.length === 0"
class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400"
>
No options available
</div>
</div>
</Transition>
</div>
</div>
<!-- Selected count indicator for multiple -->
<div
v-if="multiple && hasSelection"
class="mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{ selectedCount }} selected
</div>
<!-- Clear button -->
<button
v-if="clearable && hasSelection"
@click="clearSelection"
class="mt-2 text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
:disabled="disabled"
>
Clear {{ multiple ? "all" : "selection" }}
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import Icon from "@/components/ui/Icon.vue";
interface Option {
value: string | number;
label: string;
count?: number;
disabled?: boolean;
}
interface Props {
id: string;
label: string;
value?: string | number | (string | number)[];
options: (Option | string | number)[];
multiple?: boolean;
placeholder?: string;
required?: boolean;
disabled?: boolean;
clearable?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
multiple: false,
required: false,
disabled: false,
clearable: true,
});
const emit = defineEmits<{
update: [value: string | number | (string | number)[] | undefined];
}>();
// Local state
const isOpen = ref(false);
// Computed
const normalizedOptions = computed((): Option[] => {
return props.options.map((option) => {
if (typeof option === "string" || typeof option === "number") {
return {
value: option,
label: String(option),
};
}
return option;
});
});
const selectedValues = computed(() => {
if (!props.multiple) {
return props.value ? [props.value] : [];
}
return Array.isArray(props.value) ? props.value : [];
});
const selectedOptions = computed(() => {
return normalizedOptions.value.filter((option) =>
selectedValues.value.includes(option.value)
);
});
const hasSelection = computed(() => {
return selectedValues.value.length > 0;
});
const selectedCount = computed(() => {
return selectedValues.value.length;
});
// Methods
const handleSingleChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
emit("update", value || undefined);
};
const toggleDropdown = () => {
if (!props.disabled) {
isOpen.value = !isOpen.value;
}
};
const toggleOption = (optionValue: string | number) => {
if (props.disabled) return;
if (!props.multiple) {
emit("update", optionValue);
isOpen.value = false;
return;
}
const currentValues = Array.isArray(props.value) ? [...props.value] : [];
const index = currentValues.indexOf(optionValue);
if (index >= 0) {
currentValues.splice(index, 1);
} else {
currentValues.push(optionValue);
}
emit("update", currentValues.length > 0 ? currentValues : undefined);
};
const removeOption = (optionValue: string | number) => {
if (props.disabled) return;
if (!props.multiple) {
emit("update", undefined);
return;
}
const currentValues = Array.isArray(props.value) ? [...props.value] : [];
const index = currentValues.indexOf(optionValue);
if (index >= 0) {
currentValues.splice(index, 1);
emit("update", currentValues.length > 0 ? currentValues : undefined);
}
};
const isSelected = (optionValue: string | number): boolean => {
return selectedValues.value.includes(optionValue);
};
const clearSelection = () => {
if (!props.disabled) {
emit("update", undefined);
}
};
// Handle clicks outside
const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement;
if (!target.closest(".multi-select-container")) {
isOpen.value = false;
}
};
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
</script>
<style scoped>
.select-input {
@apply transition-colors duration-200;
}
.select-input:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
.select-input:disabled {
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
}
.select-trigger {
@apply transition-colors duration-200;
}
.select-trigger:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
.select-trigger:disabled {
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
}
.dropdown-menu {
@apply border shadow-lg;
}
.dropdown-item {
@apply transition-colors duration-150;
}
.dropdown-item:first-child {
@apply rounded-t-md;
}
.dropdown-item:last-child {
@apply rounded-b-md;
}
/* Custom scrollbar for dropdown */
.dropdown-menu::-webkit-scrollbar {
@apply w-2;
}
.dropdown-menu::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-700;
}
.dropdown-menu::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
}
.dropdown-menu::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
</style>

View File

@@ -0,0 +1,289 @@
<template>
<div
class="group relative bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-all duration-200 border border-gray-200 dark:border-gray-700 overflow-hidden cursor-pointer"
@click="$emit('click')"
>
<!-- Ride Image -->
<div class="aspect-w-16 aspect-h-9 bg-gray-100 dark:bg-gray-700">
<img
v-if="ride.image_url"
:src="ride.image_url"
:alt="ride.name"
class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-200"
/>
<div
v-else
class="w-full h-48 flex items-center justify-center bg-gradient-to-br from-blue-500 to-purple-600"
>
<Icon name="camera" class="w-12 h-12 text-white opacity-60" />
</div>
</div>
<!-- Status Badge -->
<div class="absolute top-3 right-3">
<span
:class="[
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
getStatusColor(ride.status),
]"
>
{{ getStatusDisplay(ride.status) }}
</span>
</div>
<!-- Content -->
<div class="p-4">
<!-- Ride Name and Category -->
<div class="mb-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 line-clamp-1">
{{ ride.name }}
</h3>
<p class="text-sm text-blue-600 dark:text-blue-400 font-medium">
{{ ride.category_display || ride.category }}
</p>
</div>
<!-- Park Name -->
<div class="flex items-center mb-3">
<Icon name="map-pin" class="w-4 h-4 text-gray-400 mr-1.5" />
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ ride.park_name }}
</span>
</div>
<!-- Stats Row -->
<div class="grid grid-cols-2 gap-3 mb-3 text-sm">
<!-- Height -->
<div v-if="ride.height_ft" class="flex items-center">
<Icon name="trending-up" class="w-4 h-4 text-gray-400 mr-1.5" />
<span class="text-gray-600 dark:text-gray-400">{{ ride.height_ft }}ft</span>
</div>
<!-- Speed -->
<div v-if="ride.speed_mph" class="flex items-center">
<Icon name="zap" class="w-4 h-4 text-gray-400 mr-1.5" />
<span class="text-gray-600 dark:text-gray-400">{{ ride.speed_mph }} mph</span>
</div>
<!-- Duration -->
<div v-if="ride.ride_duration_seconds" class="flex items-center">
<Icon name="clock" class="w-4 h-4 text-gray-400 mr-1.5" />
<span class="text-gray-600 dark:text-gray-400">
{{ formatDuration(ride.ride_duration_seconds) }}
</span>
</div>
<!-- Capacity -->
<div v-if="ride.capacity_per_hour" class="flex items-center">
<Icon name="users" class="w-4 h-4 text-gray-400 mr-1.5" />
<span class="text-gray-600 dark:text-gray-400">
{{ formatCapacity(ride.capacity_per_hour) }}/hr
</span>
</div>
</div>
<!-- Rating and Opening Date -->
<div class="flex items-center justify-between">
<!-- Rating -->
<div v-if="ride.average_rating" class="flex items-center">
<Icon name="star" class="w-4 h-4 text-yellow-400 mr-1" />
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ ride.average_rating.toFixed(1) }}
</span>
<span class="text-sm text-gray-500 dark:text-gray-400 ml-1">
({{ ride.review_count || 0 }})
</span>
</div>
<!-- Opening Date -->
<div v-if="ride.opening_date" class="text-xs text-gray-500 dark:text-gray-400">
Opened {{ formatYear(ride.opening_date) }}
</div>
</div>
<!-- Description Preview -->
<div
v-if="ride.description"
class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700"
>
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ ride.description }}
</p>
</div>
<!-- Manufacturer/Designer -->
<div
v-if="ride.manufacturer_name || ride.designer_name"
class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700"
>
<div class="flex flex-wrap gap-2 text-xs">
<span
v-if="ride.manufacturer_name"
class="inline-flex items-center px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
>
<Icon name="building" class="w-3 h-3 mr-1" />
{{ ride.manufacturer_name }}
</span>
<span
v-if="ride.designer_name"
class="inline-flex items-center px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
>
<Icon name="user" class="w-3 h-3 mr-1" />
{{ ride.designer_name }}
</span>
</div>
</div>
<!-- Special Features -->
<div
v-if="hasSpecialFeatures"
class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700"
>
<div class="flex flex-wrap gap-1.5">
<span
v-if="ride.inversions && ride.inversions > 0"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200"
>
{{ ride.inversions }} inversion{{ ride.inversions !== 1 ? "s" : "" }}
</span>
<span
v-if="ride.launch_type"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200"
>
{{ ride.launch_type }} launch
</span>
<span
v-if="ride.track_material"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200"
>
{{ ride.track_material }}
</span>
</div>
</div>
</div>
<!-- Hover Overlay -->
<div
class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-5 transition-all duration-200 pointer-events-none"
></div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { Ride } from "@/types";
import Icon from "@/components/ui/Icon.vue";
// Props
interface Props {
ride: Ride;
}
const props = defineProps<Props>();
// Emits
defineEmits<{
click: [];
}>();
// Computed properties
const hasSpecialFeatures = computed(() => {
return !!(
(props.ride.inversions && props.ride.inversions > 0) ||
props.ride.launch_type ||
props.ride.track_material
);
});
// Methods
const getStatusColor = (status: string) => {
switch (status?.toLowerCase()) {
case "operating":
return "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200";
case "closed":
case "permanently_closed":
return "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200";
case "under_construction":
return "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200";
case "seasonal":
return "bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200";
case "maintenance":
return "bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200";
default:
return "bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200";
}
};
const getStatusDisplay = (status: string) => {
switch (status?.toLowerCase()) {
case "operating":
return "Operating";
case "closed":
return "Closed";
case "permanently_closed":
return "Permanently Closed";
case "under_construction":
return "Under Construction";
case "seasonal":
return "Seasonal";
case "maintenance":
return "Maintenance";
default:
return status || "Unknown";
}
};
const formatDuration = (seconds: number) => {
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (remainingSeconds === 0) {
return `${minutes}m`;
}
return `${minutes}m ${remainingSeconds}s`;
};
const formatCapacity = (capacity: number) => {
if (capacity >= 1000) {
return `${(capacity / 1000).toFixed(1)}k`;
}
return capacity.toString();
};
const formatYear = (dateString: string) => {
return new Date(dateString).getFullYear();
};
</script>
<style scoped>
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.aspect-w-16 {
position: relative;
padding-bottom: calc(9 / 16 * 100%);
}
.aspect-w-16 > * {
position: absolute;
height: 100%;
width: 100%;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
</style>

View File

@@ -0,0 +1,516 @@
<template>
<div class="ride-list-display">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div class="flex items-center space-x-3">
<Icon name="spinner" class="w-6 h-6 text-blue-600 animate-spin" />
<span class="text-gray-600 dark:text-gray-300">Loading rides...</span>
</div>
</div>
<!-- Error State -->
<div
v-else-if="error"
class="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-200 dark:border-red-800"
>
<div class="flex items-center">
<Icon name="exclamation-triangle" class="w-5 h-5 text-red-500 mr-2" />
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">
Error loading rides
</h3>
</div>
<p class="mt-2 text-sm text-red-700 dark:text-red-300">{{ error }}</p>
<button
@click="retrySearch"
class="mt-3 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-500 focus:outline-none focus:underline"
>
Try again
</button>
</div>
<!-- Results Header -->
<div v-else-if="rides.length > 0 || hasActiveFilters" class="space-y-4">
<!-- Results Count and Sort -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex items-center space-x-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
<span class="font-medium text-gray-900 dark:text-gray-100">{{
totalCount
}}</span>
{{ totalCount === 1 ? "ride" : "rides" }} found
<span v-if="hasActiveFilters" class="text-gray-500 dark:text-gray-400">
with active filters
</span>
</p>
<!-- Active Filters Count -->
<span
v-if="activeFilterCount > 0"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200"
>
{{ activeFilterCount }}
{{ activeFilterCount === 1 ? "filter" : "filters" }} active
</span>
</div>
<!-- Sort Controls -->
<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"
v-model="currentSort"
@change="handleSortChange"
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="name">Name (A-Z)</option>
<option value="-name">Name (Z-A)</option>
<option value="-opening_date">Newest First</option>
<option value="opening_date">Oldest First</option>
<option value="-average_rating">Highest Rated</option>
<option value="average_rating">Lowest Rated</option>
<option value="-height_ft">Tallest First</option>
<option value="height_ft">Shortest First</option>
<option value="-speed_mph">Fastest First</option>
<option value="speed_mph">Slowest First</option>
</select>
</div>
</div>
<!-- Active Filters Display -->
<div v-if="activeFilters.length > 0" class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Active filters:</span
>
<ActiveFilterChip
v-for="filter in activeFilters"
:key="filter.key"
:label="filter.label"
:value="filter.value"
@remove="removeFilter(filter)"
/>
<button
@click="clearAllFilters"
class="ml-2 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-500 focus:outline-none focus:underline"
>
Clear all
</button>
</div>
<!-- Ride Grid -->
<div
v-if="rides.length > 0"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
<RideCard
v-for="ride in rides"
:key="ride.id"
:ride="ride"
@click="handleRideClick(ride)"
/>
</div>
<!-- No Results -->
<div v-else class="text-center py-12">
<Icon
name="search"
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 mb-4"
/>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
No rides found
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
No rides match your current search criteria.
</p>
<button
@click="clearAllFilters"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Clear all filters
</button>
</div>
<!-- Pagination -->
<div
v-if="totalPages > 1"
class="flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700"
>
<div class="flex-1 flex justify-between sm:hidden">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage <= 1"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700 dark:text-gray-300">
Showing <span class="font-medium">{{ startItem }}</span> to
<span class="font-medium">{{ endItem }}</span> of{' '}
<span class="font-medium">{{ totalCount }}</span> results
</p>
</div>
<div>
<nav
class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
>
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage <= 1"
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Icon name="chevron-left" class="h-5 w-5" />
</button>
<button
v-for="page in visiblePages"
:key="page"
@click="goToPage(page)"
:class="[
'relative inline-flex items-center px-4 py-2 border text-sm font-medium',
page === currentPage
? 'z-10 bg-blue-50 dark:bg-blue-900/30 border-blue-500 text-blue-600 dark:text-blue-400'
: 'bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700',
]"
>
{{ page }}
</button>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Icon name="chevron-right" class="h-5 w-5" />
</button>
</nav>
</div>
</div>
</div>
</div>
<!-- Empty State (no filters) -->
<div v-else class="text-center py-12">
<Icon
name="search"
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 mb-4"
/>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
Find amazing rides
</h3>
<p class="text-gray-600 dark:text-gray-400">
Use the search and filters to discover rides that match your interests.
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from "vue";
import { useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { useRideFilteringStore } from "@/stores/rideFiltering";
import type { Ride } from "@/types";
import Icon from "@/components/ui/Icon.vue";
import ActiveFilterChip from "@/components/filters/ActiveFilterChip.vue";
import RideCard from "@/components/rides/RideCard.vue";
// Props
interface Props {
parkSlug?: string;
}
const props = withDefaults(defineProps<Props>(), {
parkSlug: undefined,
});
// Composables
const router = useRouter();
const rideFilteringStore = useRideFilteringStore();
// Store state
const {
rides,
isLoading,
error,
totalCount,
totalPages,
currentPage,
filters,
} = storeToRefs(rideFilteringStore);
// Computed properties
const currentSort = computed({
get: () => filters.value.sort,
set: (value: string) => {
rideFilteringStore.updateFilters({ sort: value });
},
});
const hasActiveFilters = computed(() => {
const f = filters.value;
return !!(
f.search ||
f.categories.length > 0 ||
f.manufacturers.length > 0 ||
f.designers.length > 0 ||
f.parks.length > 0 ||
f.status.length > 0 ||
f.heightRange[0] > 0 ||
f.heightRange[1] < 500 ||
f.speedRange[0] > 0 ||
f.speedRange[1] < 200 ||
f.capacityRange[0] > 0 ||
f.capacityRange[1] < 10000 ||
f.durationRange[0] > 0 ||
f.durationRange[1] < 600 ||
f.openingDateRange[0] ||
f.openingDateRange[1] ||
f.closingDateRange[0] ||
f.closingDateRange[1]
);
});
const activeFilterCount = computed(() => {
const f = filters.value;
let count = 0;
if (f.search) count++;
if (f.categories.length > 0) count++;
if (f.manufacturers.length > 0) count++;
if (f.designers.length > 0) count++;
if (f.parks.length > 0) count++;
if (f.status.length > 0) count++;
if (f.heightRange[0] > 0 || f.heightRange[1] < 500) count++;
if (f.speedRange[0] > 0 || f.speedRange[1] < 200) count++;
if (f.capacityRange[0] > 0 || f.capacityRange[1] < 10000) count++;
if (f.durationRange[0] > 0 || f.durationRange[1] < 600) count++;
if (f.openingDateRange[0] || f.openingDateRange[1]) count++;
if (f.closingDateRange[0] || f.closingDateRange[1]) count++;
return count;
});
const activeFilters = computed(() => {
const f = filters.value;
const active: Array<{ key: string; label: string; value: string }> = [];
if (f.search) {
active.push({ key: "search", label: "Search", value: f.search });
}
if (f.categories.length > 0) {
active.push({
key: "categories",
label: "Categories",
value: `${f.categories.length} selected`,
});
}
if (f.manufacturers.length > 0) {
active.push({
key: "manufacturers",
label: "Manufacturers",
value: `${f.manufacturers.length} selected`,
});
}
if (f.designers.length > 0) {
active.push({
key: "designers",
label: "Designers",
value: `${f.designers.length} selected`,
});
}
if (f.parks.length > 0) {
active.push({ key: "parks", label: "Parks", value: `${f.parks.length} selected` });
}
if (f.status.length > 0) {
active.push({ key: "status", label: "Status", value: `${f.status.length} selected` });
}
if (f.heightRange[0] > 0 || f.heightRange[1] < 500) {
active.push({
key: "height",
label: "Height",
value: `${f.heightRange[0]}-${f.heightRange[1]} ft`,
});
}
if (f.speedRange[0] > 0 || f.speedRange[1] < 200) {
active.push({
key: "speed",
label: "Speed",
value: `${f.speedRange[0]}-${f.speedRange[1]} mph`,
});
}
if (f.capacityRange[0] > 0 || f.capacityRange[1] < 10000) {
active.push({
key: "capacity",
label: "Capacity",
value: `${f.capacityRange[0]}-${f.capacityRange[1]}/hr`,
});
}
if (f.durationRange[0] > 0 || f.durationRange[1] < 600) {
active.push({
key: "duration",
label: "Duration",
value: `${f.durationRange[0]}-${f.durationRange[1]}s`,
});
}
if (f.openingDateRange[0] || f.openingDateRange[1]) {
const start = f.openingDateRange[0] || "earliest";
const end = f.openingDateRange[1] || "latest";
active.push({ key: "opening", label: "Opening Date", value: `${start} - ${end}` });
}
if (f.closingDateRange[0] || f.closingDateRange[1]) {
const start = f.closingDateRange[0] || "earliest";
const end = f.closingDateRange[1] || "latest";
active.push({ key: "closing", label: "Closing Date", value: `${start} - ${end}` });
}
return active;
});
const startItem = computed(() => {
return (currentPage.value - 1) * 20 + 1;
});
const endItem = computed(() => {
return Math.min(currentPage.value * 20, totalCount.value);
});
const visiblePages = computed(() => {
const total = totalPages.value;
const current = currentPage.value;
const pages: number[] = [];
// Always show first page
if (total >= 1) pages.push(1);
// Show pages around current page
const start = Math.max(2, current - 2);
const end = Math.min(total - 1, current + 2);
// Add ellipsis if there's a gap
if (start > 2) pages.push(-1); // -1 represents ellipsis
// Add pages around current
for (let i = start; i <= end; i++) {
if (i > 1 && i < total) pages.push(i);
}
// Add ellipsis if there's a gap
if (end < total - 1) pages.push(-1);
// Always show last page
if (total > 1) pages.push(total);
return pages;
});
// Methods
const handleSortChange = () => {
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
};
const removeFilter = (filter: { key: string; label: string; value: string }) => {
const updates: any = {};
switch (filter.key) {
case "search":
updates.search = "";
break;
case "categories":
updates.categories = [];
break;
case "manufacturers":
updates.manufacturers = [];
break;
case "designers":
updates.designers = [];
break;
case "parks":
updates.parks = [];
break;
case "status":
updates.status = [];
break;
case "height":
updates.heightRange = [0, 500];
break;
case "speed":
updates.speedRange = [0, 200];
break;
case "capacity":
updates.capacityRange = [0, 10000];
break;
case "duration":
updates.durationRange = [0, 600];
break;
case "opening":
updates.openingDateRange = [null, null];
break;
case "closing":
updates.closingDateRange = [null, null];
break;
}
rideFilteringStore.updateFilters(updates);
};
const clearAllFilters = () => {
rideFilteringStore.resetFilters();
};
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
rideFilteringStore.updateFilters({ page });
}
};
const retrySearch = () => {
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
};
const handleRideClick = (ride: Ride) => {
if (props.parkSlug) {
router.push(`/parks/${props.parkSlug}/rides/${ride.slug}`);
} else {
router.push(`/rides/${ride.slug}`);
}
};
// Watch for filter changes
watch(
() => filters.value,
() => {
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
},
{ deep: true }
);
// Initialize search on mount
rideFilteringStore.searchRides({ parkSlug: props.parkSlug });
</script>

View File

@@ -0,0 +1,505 @@
<template>
<svg
:class="classes"
:width="size"
:height="size"
:viewBox="viewBox"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Search icon -->
<path
v-if="name === 'search'"
d="m21 21-6-6m2-5a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Filter icon -->
<path
v-else-if="name === 'filter'"
d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- X (close) icon -->
<g v-else-if="name === 'x'">
<path
d="m18 6-12 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="m6 6 12 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Chevron down icon -->
<path
v-else-if="name === 'chevron-down'"
d="m6 9 6 6 6-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Chevron up icon -->
<path
v-else-if="name === 'chevron-up'"
d="m18 15-6-6-6 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Chevron right icon -->
<path
v-else-if="name === 'chevron-right'"
d="m9 18 6-6-6-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Chevron left icon -->
<path
v-else-if="name === 'chevron-left'"
d="m15 18-6-6 6-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Check icon -->
<path
v-else-if="name === 'check'"
d="M20 6 9 17l-5-5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Plus icon -->
<g v-else-if="name === 'plus'">
<path
d="M12 5v14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5 12h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Minus icon -->
<path
v-else-if="name === 'minus'"
d="M5 12h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Calendar icon -->
<g v-else-if="name === 'calendar'">
<path
d="M8 2v4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M16 2v4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<rect
width="18"
height="18"
x="3"
y="4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
rx="2"
/>
<path
d="M3 10h18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Clock icon -->
<g v-else-if="name === 'clock'">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
<polyline
points="12,6 12,12 16,14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Star icon (outline) -->
<path
v-else-if="name === 'star'"
d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Star icon (filled) -->
<path
v-else-if="name === 'star-filled'"
d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2Z"
fill="currentColor"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- More vertical (three dots) icon -->
<g v-else-if="name === 'more-vertical'">
<circle cx="12" cy="12" r="1" fill="currentColor" />
<circle cx="12" cy="5" r="1" fill="currentColor" />
<circle cx="12" cy="19" r="1" fill="currentColor" />
</g>
<!-- Edit icon -->
<g v-else-if="name === 'edit'">
<path
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Copy icon -->
<g v-else-if="name === 'copy'">
<rect
width="14"
height="14"
x="8"
y="8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
rx="2"
ry="2"
/>
<path
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Trash icon -->
<g v-else-if="name === 'trash'">
<path
d="M3 6h18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6m3 0V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="10"
x2="10"
y1="11"
y2="17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="14"
x2="14"
y1="11"
y2="17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Loading spinner icon -->
<path
v-else-if="name === 'loading'"
d="M21 12a9 9 0 1 1-6.219-8.56"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Settings icon -->
<g v-else-if="name === 'settings'">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
<path
d="M12 1v6m0 6v6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="m21 12-6-3 6-3"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="m9 12-6 3 6 3"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Reset/refresh icon -->
<g v-else-if="name === 'refresh'">
<path
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M21 3v5h-5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8 16H3v5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Save icon -->
<g v-else-if="name === 'save'">
<path
d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="17,21 17,13 7,13 7,21"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="7,3 7,8 15,8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Alert circle icon -->
<g v-else-if="name === 'alert-circle'">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
<line
x1="12"
x2="12"
y1="8"
y2="12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="16" r="1" fill="currentColor" />
</g>
<!-- Info icon -->
<g v-else-if="name === 'info'">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
<path
d="M12 16v-4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="8" r="1" fill="currentColor" />
</g>
<!-- External link icon -->
<g v-else-if="name === 'external-link'">
<path
d="M15 3h6v6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M10 14 21 3"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Eye icon -->
<g v-else-if="name === 'eye'">
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
</g>
<!-- Eye off icon -->
<g v-else-if="name === 'eye-off'">
<path
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="m1 1 22 22"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Fallback: question mark for unknown icons -->
<g v-else>
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
<path
d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="17" r="1" fill="currentColor" />
</g>
</svg>
</template>
<script setup lang="ts">
import { computed } from "vue";
interface Props {
name: string;
size?: number | string;
class?: string;
}
const props = withDefaults(defineProps<Props>(), {
size: 24,
});
// Computed
const viewBox = computed(() => "0 0 24 24");
const classes = computed(() => {
const baseClasses = ["inline-block", "flex-shrink-0"];
if (props.class) {
baseClasses.push(props.class);
}
return baseClasses.join(" ");
});
</script>
<style scoped>
/* Ensure icons maintain their aspect ratio */
svg {
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,379 @@
/**
* Composable for ride filtering API communication
*/
import { ref, computed, watch, type Ref } from 'vue'
import { api } from '@/services/api'
import type {
Ride,
RideFilters,
FilterOptions,
CompanySearchResult,
RideModelSearchResult,
SearchSuggestion,
ApiResponse
} from '@/types'
export function useRideFiltering(initialFilters: RideFilters = {}) {
// State
const isLoading = ref(false)
const error = ref<string | null>(null)
const rides = ref<Ride[]>([])
const totalCount = ref(0)
const currentPage = ref(1)
const hasNextPage = ref(false)
const hasPreviousPage = ref(false)
// Filter state
const filters = ref<RideFilters>({ ...initialFilters })
const filterOptions = ref<FilterOptions | null>(null)
// Debounced search
const searchDebounceTimeout = ref<number | null>(null)
// Computed
const hasActiveFilters = computed(() => {
return Object.values(filters.value).some(value => {
if (Array.isArray(value)) return value.length > 0
return value !== undefined && value !== null && value !== ''
})
})
const isFirstLoad = computed(() => rides.value.length === 0 && !isLoading.value)
/**
* Build query parameters from filters
*/
const buildQueryParams = (filterData: RideFilters): Record<string, string> => {
const params: Record<string, string> = {}
Object.entries(filterData).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
if (Array.isArray(value)) {
if (value.length > 0) {
params[key] = value.join(',')
}
} else {
params[key] = String(value)
}
})
return params
}
/**
* Fetch rides with current filters
*/
const fetchRides = async (resetPagination = true) => {
if (resetPagination) {
currentPage.value = 1
filters.value.page = 1
}
isLoading.value = true
error.value = null
try {
const queryParams = buildQueryParams(filters.value)
const response = await api.client.get<ApiResponse<Ride>>('/rides/api/', queryParams)
rides.value = response.results
totalCount.value = response.count
hasNextPage.value = !!response.next
hasPreviousPage.value = !!response.previous
return response
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch rides'
console.error('Error fetching rides:', err)
throw err
} finally {
isLoading.value = false
}
}
/**
* Load more rides (pagination)
*/
const loadMore = async () => {
if (!hasNextPage.value || isLoading.value) return
currentPage.value += 1
filters.value.page = currentPage.value
isLoading.value = true
try {
const queryParams = buildQueryParams(filters.value)
const response = await api.client.get<ApiResponse<Ride>>('/rides/api/', queryParams)
rides.value.push(...response.results)
totalCount.value = response.count
hasNextPage.value = !!response.next
hasPreviousPage.value = !!response.previous
return response
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load more rides'
console.error('Error loading more rides:', err)
throw err
} finally {
isLoading.value = false
}
}
/**
* Fetch filter options from API
*/
const fetchFilterOptions = async () => {
try {
const response = await api.client.get<FilterOptions>('/rides/api/filter-options/')
filterOptions.value = response
return response
} catch (err) {
console.error('Error fetching filter options:', err)
throw err
}
}
/**
* Search companies for manufacturer/designer autocomplete
*/
const searchCompanies = async (query: string, role?: 'manufacturer' | 'designer' | 'both'): Promise<CompanySearchResult[]> => {
if (!query.trim()) return []
try {
const params: Record<string, string> = { q: query }
if (role) params.role = role
const response = await api.client.get<CompanySearchResult[]>('/rides/api/search-companies/', params)
return response
} catch (err) {
console.error('Error searching companies:', err)
return []
}
}
/**
* Search ride models for autocomplete
*/
const searchRideModels = async (query: string): Promise<RideModelSearchResult[]> => {
if (!query.trim()) return []
try {
const response = await api.client.get<RideModelSearchResult[]>('/rides/api/search-ride-models/', { q: query })
return response
} catch (err) {
console.error('Error searching ride models:', err)
return []
}
}
/**
* Get search suggestions
*/
const getSearchSuggestions = async (query: string): Promise<SearchSuggestion[]> => {
if (!query.trim()) return []
try {
const response = await api.client.get<SearchSuggestion[]>('/rides/api/search-suggestions/', { q: query })
return response
} catch (err) {
console.error('Error getting search suggestions:', err)
return []
}
}
/**
* Update a specific filter
*/
const updateFilter = (key: keyof RideFilters, value: any) => {
filters.value = {
...filters.value,
[key]: value
}
}
/**
* Update multiple filters at once
*/
const updateFilters = (newFilters: Partial<RideFilters>) => {
filters.value = {
...filters.value,
...newFilters
}
}
/**
* Clear all filters
*/
const clearFilters = () => {
filters.value = {}
currentPage.value = 1
}
/**
* Clear a specific filter
*/
const clearFilter = (key: keyof RideFilters) => {
const newFilters = { ...filters.value }
delete newFilters[key]
filters.value = newFilters
}
/**
* Debounced search for text inputs
*/
const debouncedSearch = (query: string, delay = 500) => {
if (searchDebounceTimeout.value) {
clearTimeout(searchDebounceTimeout.value)
}
searchDebounceTimeout.value = window.setTimeout(() => {
updateFilter('search', query)
fetchRides()
}, delay)
}
/**
* Set sorting
*/
const setSorting = (ordering: string) => {
updateFilter('ordering', ordering)
fetchRides()
}
/**
* Set page size
*/
const setPageSize = (pageSize: number) => {
updateFilter('page_size', pageSize)
fetchRides()
}
/**
* Export current results
*/
const exportResults = async (format: 'csv' | 'json' = 'csv') => {
try {
const queryParams = buildQueryParams({
...filters.value,
export: format,
page_size: 1000 // Export more results
})
const response = await fetch(`${api.getBaseUrl()}/rides/api/?${new URLSearchParams(queryParams)}`, {
headers: {
'Accept': format === 'csv' ? 'text/csv' : 'application/json'
}
})
if (!response.ok) throw new Error('Export failed')
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `rides_export.${format}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
} catch (err) {
console.error('Error exporting results:', err)
throw err
}
}
// Watch for filter changes and auto-fetch
watch(
() => filters.value,
(newFilters, oldFilters) => {
// Skip if this is the initial setup
if (!oldFilters) return
// Don't auto-fetch for search queries (use debounced search instead)
if (newFilters.search !== oldFilters.search) return
// Auto-fetch for other filter changes
fetchRides()
},
{ deep: true }
)
return {
// State
isLoading,
error,
rides,
totalCount,
currentPage,
hasNextPage,
hasPreviousPage,
filters,
filterOptions,
// Computed
hasActiveFilters,
isFirstLoad,
// Methods
fetchRides,
loadMore,
fetchFilterOptions,
searchCompanies,
searchRideModels,
getSearchSuggestions,
updateFilter,
updateFilters,
clearFilters,
clearFilter,
debouncedSearch,
setSorting,
setPageSize,
exportResults,
// Utilities
buildQueryParams
}
}
// Park-specific ride filtering
export function useParkRideFiltering(parkSlug: string, initialFilters: RideFilters = {}) {
const baseComposable = useRideFiltering(initialFilters)
// Override the fetch method to use park-specific endpoint
const fetchRides = async (resetPagination = true) => {
if (resetPagination) {
baseComposable.currentPage.value = 1
baseComposable.filters.value.page = 1
}
baseComposable.isLoading.value = true
baseComposable.error.value = null
try {
const queryParams = baseComposable.buildQueryParams(baseComposable.filters.value)
const response = await api.client.get<ApiResponse<Ride>>(`/parks/${parkSlug}/rides/`, queryParams)
baseComposable.rides.value = response.results
baseComposable.totalCount.value = response.count
baseComposable.hasNextPage.value = !!response.next
baseComposable.hasPreviousPage.value = !!response.previous
return response
} catch (err) {
baseComposable.error.value = err instanceof Error ? err.message : 'Failed to fetch park rides'
console.error('Error fetching park rides:', err)
throw err
} finally {
baseComposable.isLoading.value = false
}
}
return {
...baseComposable,
fetchRides
}
}

View File

@@ -0,0 +1,100 @@
import { ref, watch, onMounted } from 'vue'
// Theme state
const isDark = ref(false)
// Theme management composable
export function useTheme() {
// Initialize theme from localStorage or system preference
const initializeTheme = () => {
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
isDark.value = savedTheme === 'dark'
} else {
// Check system preference
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
}
updateTheme()
}
// Update theme in DOM and localStorage
const updateTheme = () => {
if (isDark.value) {
document.documentElement.classList.add('dark')
localStorage.setItem('theme', 'dark')
} else {
document.documentElement.classList.remove('dark')
localStorage.setItem('theme', 'light')
}
}
// Toggle theme
const toggleTheme = () => {
isDark.value = !isDark.value
updateTheme()
}
// Set specific theme
const setTheme = (theme: 'light' | 'dark') => {
isDark.value = theme === 'dark'
updateTheme()
}
// Watch for changes and update DOM
watch(isDark, updateTheme, { immediate: false })
// Listen for system theme changes
const watchSystemTheme = () => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (e: MediaQueryListEvent) => {
// Only update if user hasn't set a manual preference
if (!localStorage.getItem('theme')) {
isDark.value = e.matches
}
}
mediaQuery.addEventListener('change', handleChange)
// Return cleanup function
return () => mediaQuery.removeEventListener('change', handleChange)
}
// Auto-initialize on mount
onMounted(() => {
initializeTheme()
watchSystemTheme()
})
return {
isDark,
toggleTheme,
setTheme,
initializeTheme
}
}
// Global theme utilities
export const themeUtils = {
// Get current theme
getCurrentTheme: (): 'light' | 'dark' => {
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
},
// Check if dark mode is preferred by system
getSystemTheme: (): 'light' | 'dark' => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
},
// Force apply theme without composable
applyTheme: (theme: 'light' | 'dark') => {
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
localStorage.setItem('theme', theme)
}
}

View File

@@ -607,6 +607,57 @@ export class RidesApi {
rides: Ride[]
}>('/api/rides/recent_relocations/', params)
}
/**
* Get filter options for ride filtering
*/
async getFilterOptions(): Promise<any> {
return this.client.get('/rides/api/filter-options/')
}
/**
* Search companies for manufacturer/designer autocomplete
*/
async searchCompanies(query: string, role?: 'manufacturer' | 'designer' | 'both'): Promise<any[]> {
const params: Record<string, string> = { q: query }
if (role) params.role = role
return this.client.get('/rides/api/search-companies/', params)
}
/**
* Search ride models for autocomplete
*/
async searchRideModels(query: string): Promise<any[]> {
return this.client.get('/rides/api/search-ride-models/', { q: query })
}
/**
* Get search suggestions
*/
async getSearchSuggestions(query: string): Promise<any[]> {
return this.client.get('/rides/api/search-suggestions/', { q: query })
}
/**
* Advanced ride filtering with comprehensive options
*/
async getFilteredRides(filters: Record<string, any>): Promise<ApiResponse<Ride>> {
const params: Record<string, string> = {}
Object.entries(filters).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
if (Array.isArray(value)) {
if (value.length > 0) {
params[key] = value.join(',')
}
} else {
params[key] = String(value)
}
})
return this.client.get<ApiResponse<Ride>>('/rides/api/', params)
}
}
/**

View File

@@ -0,0 +1,441 @@
/**
* Pinia store for ride filtering state management
*/
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import type {
RideFilters,
FilterOptions,
ActiveFilter,
FilterFormState,
Ride
} from '@/types'
import { useRideFiltering } from '@/composables/useRideFiltering'
export const useRideFilteringStore = defineStore('rideFiltering', () => {
// Core state
const filters = ref<RideFilters>({})
const filterOptions = ref<FilterOptions | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// UI state
const formState = ref<FilterFormState>({
isOpen: false,
expandedSections: {
search: true,
basic: true,
manufacturer: false,
specifications: false,
dates: false,
location: false,
advanced: false
},
hasChanges: false,
appliedFilters: {},
pendingFilters: {}
})
// Results state
const rides = ref<Ride[]>([])
const totalCount = ref(0)
const currentPage = ref(1)
const hasNextPage = ref(false)
const hasPreviousPage = ref(false)
// Search state
const searchQuery = ref('')
const searchSuggestions = ref<any[]>([])
const showSuggestions = ref(false)
// Filter presets
const savedPresets = ref<any[]>([])
const currentPreset = ref<string | null>(null)
// Computed properties
const hasActiveFilters = computed(() => {
return Object.values(filters.value).some(value => {
if (Array.isArray(value)) return value.length > 0
return value !== undefined && value !== null && value !== ''
})
})
const activeFiltersCount = computed(() => {
let count = 0
Object.entries(filters.value).forEach(([key, value]) => {
if (key === 'page' || key === 'page_size' || key === 'ordering') return
if (Array.isArray(value) && value.length > 0) count++
else if (value !== undefined && value !== null && value !== '') count++
})
return count
})
const activeFiltersList = computed((): ActiveFilter[] => {
const list: ActiveFilter[] = []
Object.entries(filters.value).forEach(([key, value]) => {
if (!value || key === 'page' || key === 'page_size') return
if (Array.isArray(value) && value.length > 0) {
list.push({
key,
label: getFilterLabel(key),
value: value.join(', '),
displayValue: value.join(', '),
category: 'select'
})
} else if (value !== undefined && value !== null && value !== '') {
let displayValue = String(value)
let category: 'search' | 'select' | 'range' | 'date' = 'select'
if (key === 'search') {
category = 'search'
} else if (key.includes('_min') || key.includes('_max')) {
category = 'range'
displayValue = formatRangeValue(key, value)
} else if (key.includes('date')) {
category = 'date'
displayValue = formatDateValue(value)
}
list.push({
key,
label: getFilterLabel(key),
value,
displayValue,
category
})
}
})
return list
})
const isFilterFormOpen = computed(() => formState.value.isOpen)
const hasUnsavedChanges = computed(() => formState.value.hasChanges)
// Helper functions
const getFilterLabel = (key: string): string => {
const labels: Record<string, string> = {
search: 'Search',
category: 'Category',
status: 'Status',
manufacturer: 'Manufacturer',
designer: 'Designer',
park: 'Park',
country: 'Country',
region: 'Region',
height_min: 'Min Height',
height_max: 'Max Height',
speed_min: 'Min Speed',
speed_max: 'Max Speed',
length_min: 'Min Length',
length_max: 'Max Length',
capacity_min: 'Min Capacity',
capacity_max: 'Max Capacity',
duration_min: 'Min Duration',
duration_max: 'Max Duration',
inversions_min: 'Min Inversions',
inversions_max: 'Max Inversions',
opening_date_from: 'Opened After',
opening_date_to: 'Opened Before',
closing_date_from: 'Closed After',
closing_date_to: 'Closed Before',
ordering: 'Sort By'
}
return labels[key] || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
}
const formatRangeValue = (key: string, value: any): string => {
const numValue = Number(value)
if (key.includes('height')) return `${numValue}m`
if (key.includes('speed')) return `${numValue} km/h`
if (key.includes('length')) return `${numValue}m`
if (key.includes('duration')) return `${numValue}s`
if (key.includes('capacity')) return `${numValue} people`
return String(value)
}
const formatDateValue = (value: any): string => {
if (typeof value === 'string') {
const date = new Date(value)
return date.toLocaleDateString()
}
return String(value)
}
// Actions
const updateFilter = (key: keyof RideFilters, value: any) => {
filters.value = {
...filters.value,
[key]: value
}
formState.value.hasChanges = true
}
const updateMultipleFilters = (newFilters: Partial<RideFilters>) => {
filters.value = {
...filters.value,
...newFilters
}
formState.value.hasChanges = true
}
const clearFilter = (key: keyof RideFilters) => {
const newFilters = { ...filters.value }
delete newFilters[key]
filters.value = newFilters
formState.value.hasChanges = true
}
const clearAllFilters = () => {
const preserveKeys = ['page_size', 'ordering']
const newFilters: RideFilters = {}
preserveKeys.forEach(key => {
if (filters.value[key as keyof RideFilters]) {
newFilters[key as keyof RideFilters] = filters.value[key as keyof RideFilters]
}
})
filters.value = newFilters
currentPage.value = 1
formState.value.hasChanges = true
}
const applyFilters = () => {
formState.value.appliedFilters = { ...filters.value }
formState.value.hasChanges = false
currentPage.value = 1
}
const resetFilters = () => {
filters.value = { ...formState.value.appliedFilters }
formState.value.hasChanges = false
}
const toggleFilterForm = () => {
formState.value.isOpen = !formState.value.isOpen
}
const openFilterForm = () => {
formState.value.isOpen = true
}
const closeFilterForm = () => {
formState.value.isOpen = false
}
const toggleSection = (sectionId: string) => {
formState.value.expandedSections[sectionId] = !formState.value.expandedSections[sectionId]
}
const expandSection = (sectionId: string) => {
formState.value.expandedSections[sectionId] = true
}
const collapseSection = (sectionId: string) => {
formState.value.expandedSections[sectionId] = false
}
const expandAllSections = () => {
Object.keys(formState.value.expandedSections).forEach(key => {
formState.value.expandedSections[key] = true
})
}
const collapseAllSections = () => {
Object.keys(formState.value.expandedSections).forEach(key => {
formState.value.expandedSections[key] = false
})
}
const setSearchQuery = (query: string) => {
searchQuery.value = query
updateFilter('search', query)
}
const setSorting = (ordering: string) => {
updateFilter('ordering', ordering)
}
const setPageSize = (pageSize: number) => {
updateFilter('page_size', pageSize)
currentPage.value = 1
}
const goToPage = (page: number) => {
currentPage.value = page
updateFilter('page', page)
}
const nextPage = () => {
if (hasNextPage.value) {
goToPage(currentPage.value + 1)
}
}
const previousPage = () => {
if (hasPreviousPage.value) {
goToPage(currentPage.value - 1)
}
}
const setRides = (newRides: Ride[]) => {
rides.value = newRides
}
const appendRides = (newRides: Ride[]) => {
rides.value.push(...newRides)
}
const setTotalCount = (count: number) => {
totalCount.value = count
}
const setPagination = (pagination: {
hasNext: boolean
hasPrevious: boolean
totalCount: number
}) => {
hasNextPage.value = pagination.hasNext
hasPreviousPage.value = pagination.hasPrevious
totalCount.value = pagination.totalCount
}
const setLoading = (loading: boolean) => {
isLoading.value = loading
}
const setError = (errorMessage: string | null) => {
error.value = errorMessage
}
const setFilterOptions = (options: FilterOptions) => {
filterOptions.value = options
}
const setSearchSuggestions = (suggestions: any[]) => {
searchSuggestions.value = suggestions
}
const showSearchSuggestions = () => {
showSuggestions.value = true
}
const hideSearchSuggestions = () => {
showSuggestions.value = false
}
// Preset management
const savePreset = (name: string) => {
const preset = {
id: Date.now().toString(),
name,
filters: { ...filters.value },
createdAt: new Date().toISOString()
}
savedPresets.value.push(preset)
currentPreset.value = preset.id
// Save to localStorage
localStorage.setItem('ride-filter-presets', JSON.stringify(savedPresets.value))
}
const loadPreset = (presetId: string) => {
const preset = savedPresets.value.find(p => p.id === presetId)
if (preset) {
filters.value = { ...preset.filters }
currentPreset.value = presetId
formState.value.hasChanges = true
}
}
const deletePreset = (presetId: string) => {
savedPresets.value = savedPresets.value.filter(p => p.id !== presetId)
if (currentPreset.value === presetId) {
currentPreset.value = null
}
// Update localStorage
localStorage.setItem('ride-filter-presets', JSON.stringify(savedPresets.value))
}
const loadPresetsFromStorage = () => {
try {
const stored = localStorage.getItem('ride-filter-presets')
if (stored) {
savedPresets.value = JSON.parse(stored)
}
} catch (error) {
console.error('Failed to load filter presets:', error)
}
}
// Initialize presets from localStorage
loadPresetsFromStorage()
return {
// State
filters,
filterOptions,
isLoading,
error,
formState,
rides,
totalCount,
currentPage,
hasNextPage,
hasPreviousPage,
searchQuery,
searchSuggestions,
showSuggestions,
savedPresets,
currentPreset,
// Computed
hasActiveFilters,
activeFiltersCount,
activeFiltersList,
isFilterFormOpen,
hasUnsavedChanges,
// Actions
updateFilter,
updateMultipleFilters,
clearFilter,
clearAllFilters,
applyFilters,
resetFilters,
toggleFilterForm,
openFilterForm,
closeFilterForm,
toggleSection,
expandSection,
collapseSection,
expandAllSections,
collapseAllSections,
setSearchQuery,
setSorting,
setPageSize,
goToPage,
nextPage,
previousPage,
setRides,
appendRides,
setTotalCount,
setPagination,
setLoading,
setError,
setFilterOptions,
setSearchSuggestions,
showSearchSuggestions,
hideSearchSuggestions,
savePreset,
loadPreset,
deletePreset,
loadPresetsFromStorage
}
})

View File

@@ -0,0 +1,172 @@
/**
* Enhanced filter types for comprehensive ride filtering system
*/
// Enhanced ride filter types based on backend API design
export interface RideFilters {
// Search and basic filters
search?: string
category?: string | string[]
status?: string | string[]
park?: string | string[]
// Manufacturer and design filters
manufacturer?: string | string[]
designer?: string | string[]
manufacturer_role?: 'manufacturer' | 'designer' | 'both'
// Numeric range filters
height_min?: number
height_max?: number
speed_min?: number
speed_max?: number
length_min?: number
length_max?: number
capacity_min?: number
capacity_max?: number
duration_min?: number
duration_max?: number
inversions_min?: number
inversions_max?: number
// Date range filters
opening_date_from?: string
opening_date_to?: string
closing_date_from?: string
closing_date_to?: string
// Location filters
country?: string | string[]
region?: string | string[]
// Sorting
ordering?: string
// Pagination
page?: number
page_size?: number
}
// Filter options from backend
export interface FilterOptions {
categories: FilterChoice[]
statuses: FilterChoice[]
manufacturers: FilterChoice[]
designers: FilterChoice[]
countries: FilterChoice[]
regions: FilterChoice[]
parks: FilterChoice[]
ordering_options: OrderingChoice[]
numeric_ranges: NumericRanges
}
export interface FilterChoice {
value: string
label: string
count?: number
}
export interface OrderingChoice {
value: string
label: string
direction: 'asc' | 'desc'
}
export interface NumericRanges {
height: { min: number; max: number }
speed: { min: number; max: number }
length: { min: number; max: number }
capacity: { min: number; max: number }
duration: { min: number; max: number }
inversions: { min: number; max: number }
}
// Active filter display
export interface ActiveFilter {
key: string
label: string
value: string | number
displayValue: string
category: 'search' | 'select' | 'range' | 'date'
}
// Filter form state
export interface FilterFormState {
isOpen: boolean
expandedSections: Record<string, boolean>
hasChanges: boolean
appliedFilters: RideFilters
pendingFilters: RideFilters
}
// Search suggestions
export interface SearchSuggestion {
text: string
type: 'ride' | 'park' | 'manufacturer' | 'designer'
context?: string
}
// Company search for manufacturer/designer autocomplete
export interface CompanySearchResult {
id: number
name: string
role: 'manufacturer' | 'designer' | 'both'
ride_count: number
}
// Ride model search for autocomplete
export interface RideModelSearchResult {
manufacturer: string
model: string
ride_count: number
}
// Filter section configuration
export interface FilterSection {
id: string
title: string
icon?: string
defaultExpanded: boolean
order: number
}
// Range filter configuration
export interface RangeFilterConfig {
min: number
max: number
step: number
unit?: string
format?: (value: number) => string
}
// Date range filter configuration
export interface DateRangeConfig {
minDate?: string
maxDate?: string
placeholder?: string
}
// Filter validation
export interface FilterValidation {
isValid: boolean
errors: Record<string, string[]>
}
// Filter persistence
export interface FilterPreset {
id: string
name: string
filters: RideFilters
isDefault?: boolean
createdAt: string
}
// Filter analytics
export interface FilterStats {
totalResults: number
filterBreakdown: Record<string, number>
popularFilters: Array<{
filter: string
usage: number
}>
}

View File

@@ -53,7 +53,7 @@ export interface Ride {
updated: string
}
// Search and filter types
// Search and filter types - Basic legacy interface (keeping for compatibility)
export interface SearchFilters {
query?: string
category?: string
@@ -67,6 +67,26 @@ export interface SearchFilters {
maxSpeed?: number
}
// Import comprehensive filter types
export type {
RideFilters,
FilterOptions,
FilterChoice,
OrderingChoice,
NumericRanges,
ActiveFilter,
FilterFormState,
SearchSuggestion,
CompanySearchResult,
RideModelSearchResult,
FilterSection,
RangeFilterConfig,
DateRangeConfig,
FilterValidation,
FilterPreset,
FilterStats
} from './filters'
export interface ParkFilters {
query?: string
status?: string

View File

@@ -0,0 +1,375 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
<!-- Header -->
<header
class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-40"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Left side - Title and breadcrumb -->
<div class="flex items-center space-x-4">
<nav class="flex" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2">
<li>
<router-link
to="/"
class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
<Icon name="home" class="w-5 h-5" />
</router-link>
</li>
<li>
<Icon
name="chevron-right"
class="w-4 h-4 text-gray-400 dark:text-gray-500"
/>
</li>
<li v-if="parkSlug">
<router-link
:to="`/parks/${parkSlug}`"
class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
{{ parkName || "Park" }}
</router-link>
</li>
<li v-if="parkSlug">
<Icon
name="chevron-right"
class="w-4 h-4 text-gray-400 dark:text-gray-500"
/>
</li>
<li>
<span class="text-gray-900 dark:text-gray-100 font-medium">
{{ pageTitle }}
</span>
</li>
</ol>
</nav>
</div>
<!-- Right side - Actions -->
<div class="flex items-center space-x-4">
<!-- Filter toggle for mobile -->
<button
@click="toggleMobileFilters"
class="md:hidden inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<Icon name="filter" class="w-4 h-4 mr-2" />
Filters
<span
v-if="activeFilterCount > 0"
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200"
>
{{ activeFilterCount }}
</span>
</button>
<!-- Theme toggle -->
<button
@click="toggleTheme"
class="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
>
<Icon :name="isDark ? 'sun' : 'moon'" class="w-5 h-5" />
</button>
</div>
</div>
</div>
</header>
<!-- Main content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="lg:grid lg:grid-cols-5 lg:gap-8">
<!-- Filter sidebar -->
<aside class="lg:col-span-1">
<!-- Mobile filter overlay -->
<div
v-if="showMobileFilters"
class="fixed inset-0 z-50 lg:hidden"
@click="closeMobileFilters"
>
<div class="fixed inset-0 bg-black bg-opacity-50" />
<div
class="fixed inset-y-0 left-0 w-80 bg-white dark:bg-gray-800 shadow-xl overflow-y-auto"
>
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Filters
</h2>
<button
@click="closeMobileFilters"
class="p-2 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<Icon name="x" class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-4">
<RideFilterSidebar :park-slug="parkSlug" />
</div>
</div>
</div>
<!-- Desktop filter sidebar -->
<div class="hidden lg:block">
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 sticky top-24"
>
<div class="p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-6">
Filter Rides
</h2>
<RideFilterSidebar :park-slug="parkSlug" />
</div>
</div>
</div>
</aside>
<!-- Main content area -->
<main class="lg:col-span-4">
<!-- Page description -->
<div class="mb-6">
<h1
class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2"
>
{{ pageTitle }}
</h1>
<p class="text-gray-600 dark:text-gray-400 text-lg">
{{ pageDescription }}
</p>
</div>
<!-- Quick stats -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
<div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
>
<div class="flex items-center">
<Icon name="map-pin" class="w-8 h-8 text-blue-500 mr-3" />
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
Total Rides
</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalCount || 0 }}
</p>
</div>
</div>
</div>
<div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
>
<div class="flex items-center">
<Icon name="filter" class="w-8 h-8 text-green-500 mr-3" />
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
Active Filters
</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ activeFilterCount }}
</p>
</div>
</div>
</div>
<div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
>
<div class="flex items-center">
<Icon name="star" class="w-8 h-8 text-yellow-500 mr-3" />
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
Avg Rating
</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ averageRating ? averageRating.toFixed(1) : "--" }}
</p>
</div>
</div>
</div>
<div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
>
<div class="flex items-center">
<Icon name="zap" class="w-8 h-8 text-purple-500 mr-3" />
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
Operating
</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ operatingCount || 0 }}
</p>
</div>
</div>
</div>
</div>
<!-- Ride list -->
<div
class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<div class="p-6">
<RideListDisplay :park-slug="parkSlug" />
</div>
</div>
</main>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, watchEffect } from "vue";
import { useRoute } from "vue-router";
import { storeToRefs } from "pinia";
import { useRideFilteringStore } from "@/stores/rideFiltering";
import { useTheme } from "@/composables/useTheme";
import RideFilterSidebar from "@/components/filters/RideFilterSidebar.vue";
import RideListDisplay from "@/components/rides/RideListDisplay.vue";
import Icon from "@/components/ui/Icon.vue";
// Props
interface Props {
parkSlug?: string;
parkName?: string;
}
const props = withDefaults(defineProps<Props>(), {
parkSlug: undefined,
parkName: undefined,
});
// Composables
const route = useRoute();
const rideFilteringStore = useRideFilteringStore();
const { isDark, toggleTheme } = useTheme();
// Store state
const { totalCount, rides, filters } = storeToRefs(rideFilteringStore);
// Reactive state
const showMobileFilters = ref(false);
// Computed properties
const pageTitle = computed(() => {
if (props.parkSlug) {
return `${props.parkName || "Park"} Rides`;
}
return "All Rides";
});
const pageDescription = computed(() => {
if (props.parkSlug) {
return `Discover and explore all the exciting rides at ${
props.parkName || "this park"
}. Use the filters to find exactly what you're looking for.`;
}
return "Discover and explore amazing rides from theme parks around the world. Use the advanced filters to find exactly what you're looking for.";
});
const activeFilterCount = computed(() => {
const f = filters.value;
let count = 0;
if (f.search) count++;
if (f.categories.length > 0) count++;
if (f.manufacturers.length > 0) count++;
if (f.designers.length > 0) count++;
if (f.parks.length > 0) count++;
if (f.status.length > 0) count++;
if (f.heightRange[0] > 0 || f.heightRange[1] < 500) count++;
if (f.speedRange[0] > 0 || f.speedRange[1] < 200) count++;
if (f.capacityRange[0] > 0 || f.capacityRange[1] < 10000) count++;
if (f.durationRange[0] > 0 || f.durationRange[1] < 600) count++;
if (f.openingDateRange[0] || f.openingDateRange[1]) count++;
if (f.closingDateRange[0] || f.closingDateRange[1]) count++;
return count;
});
const averageRating = computed(() => {
if (!rides.value.length) return null;
const ridesWithRatings = rides.value.filter((ride) => ride.average_rating);
if (!ridesWithRatings.length) return null;
const sum = ridesWithRatings.reduce((acc, ride) => acc + (ride.average_rating || 0), 0);
return sum / ridesWithRatings.length;
});
const operatingCount = computed(() => {
return rides.value.filter((ride) => ride.status?.toLowerCase() === "operating").length;
});
// Methods
const toggleMobileFilters = () => {
showMobileFilters.value = !showMobileFilters.value;
};
const closeMobileFilters = () => {
showMobileFilters.value = false;
};
// Handle route changes
watchEffect(() => {
// Update park context when route changes
if (route.params.parkSlug !== props.parkSlug) {
// Reset filters when switching between park-specific and global views
rideFilteringStore.resetFilters();
}
});
// Initialize the page
onMounted(() => {
// Close mobile filters when clicking outside
document.addEventListener("click", (event) => {
const target = event.target as Element;
if (showMobileFilters.value && !target.closest(".mobile-filter-sidebar")) {
closeMobileFilters();
}
});
});
</script>
<style scoped>
/* Custom scrollbar for filter sidebar */
.mobile-filter-sidebar::-webkit-scrollbar {
width: 6px;
}
.mobile-filter-sidebar::-webkit-scrollbar-track {
background: theme("colors.gray.100");
}
.mobile-filter-sidebar::-webkit-scrollbar-thumb {
background: theme("colors.gray.400");
border-radius: 3px;
}
.mobile-filter-sidebar::-webkit-scrollbar-thumb:hover {
background: theme("colors.gray.500");
}
/* Dark mode scrollbar */
.dark .mobile-filter-sidebar::-webkit-scrollbar-track {
background: theme("colors.gray.700");
}
.dark .mobile-filter-sidebar::-webkit-scrollbar-thumb {
background: theme("colors.gray.500");
}
.dark .mobile-filter-sidebar::-webkit-scrollbar-thumb:hover {
background: theme("colors.gray.400");
}
/* Smooth transitions */
* {
transition-property: background-color, border-color, color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
</style>