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