mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 05:51: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
|
```bash
|
||||||
cd $PROJECT_ROOT/backend && uv add <package>
|
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
|
## Frontend API URL Rules
|
||||||
- **Vite Proxy:** Always check `frontend/vite.config.ts` for proxy rules before changing frontend API URLs.
|
- **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:
|
if park_slug:
|
||||||
# For nested endpoints, use the dedicated park selector
|
# For nested endpoints, use the dedicated park selector
|
||||||
from apps.rides.selectors import rides_in_park
|
from apps.rides.selectors import rides_in_park
|
||||||
|
|
||||||
return rides_in_park(park_slug=park_slug)
|
return rides_in_park(park_slug=park_slug)
|
||||||
|
|
||||||
# For global endpoints, parse filter parameters and use general selector
|
# For global endpoints, parse filter parameters and use general selector
|
||||||
filter_serializer = RideFilterInputSerializer(
|
filter_serializer = RideFilterInputSerializer(
|
||||||
data=self.request.query_params # type: ignore[attr-defined]
|
data=self.request.query_params # type: ignore[attr-defined]
|
||||||
)
|
)
|
||||||
filter_serializer.is_valid(raise_exception=True)
|
filter_serializer.is_valid(raise_exception=True)
|
||||||
filters = filter_serializer.validated_data
|
filters = filter_serializer.validated_data
|
||||||
|
|
||||||
return ride_list_for_display(filters=filters) # type: ignore[arg-type]
|
return ride_list_for_display(filters=filters) # type: ignore[arg-type]
|
||||||
|
|
||||||
# For other actions, return base queryset
|
# For other actions, return base queryset
|
||||||
|
|||||||
@@ -9,16 +9,18 @@ from .views.entity_search import (
|
|||||||
QuickEntitySuggestionView,
|
QuickEntitySuggestionView,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = 'core'
|
app_name = "core"
|
||||||
|
|
||||||
# Entity search endpoints
|
# Entity search endpoints
|
||||||
entity_patterns = [
|
entity_patterns = [
|
||||||
path('search/', EntityFuzzySearchView.as_view(), name='entity_fuzzy_search'),
|
path("search/", EntityFuzzySearchView.as_view(), name="entity_fuzzy_search"),
|
||||||
path('not-found/', EntityNotFoundView.as_view(), name='entity_not_found'),
|
path("not-found/", EntityNotFoundView.as_view(), name="entity_not_found"),
|
||||||
path('suggestions/', QuickEntitySuggestionView.as_view(), name='entity_suggestions'),
|
path(
|
||||||
|
"suggestions/", QuickEntitySuggestionView.as_view(), name="entity_suggestions"
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Entity fuzzy matching and search endpoints
|
# 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
|
from . import views
|
||||||
|
|
||||||
app_name = "rides"
|
app_name = "rides"
|
||||||
@@ -70,6 +70,8 @@ urlpatterns = [
|
|||||||
views.ranking_comparisons,
|
views.ranking_comparisons,
|
||||||
name="ranking_comparisons",
|
name="ranking_comparisons",
|
||||||
),
|
),
|
||||||
|
# API endpoints for Vue.js frontend
|
||||||
|
path("api/", include("apps.rides.api_urls", namespace="rides_api")),
|
||||||
# Park-specific URLs
|
# Park-specific URLs
|
||||||
path("create/", views.RideCreateView.as_view(), name="ride_create"),
|
path("create/", views.RideCreateView.as_view(), name="ride_create"),
|
||||||
path("<slug:ride_slug>/", views.RideDetailView.as_view(), name="ride_detail"),
|
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.rides import Ride, RideModel, Categories
|
||||||
from .models.company import Company
|
from .models.company import Company
|
||||||
from .forms import RideForm, RideSearchForm
|
from .forms import RideForm, RideSearchForm
|
||||||
|
from .forms.search import MasterFilterForm
|
||||||
|
from .services.search import RideSearchService
|
||||||
from apps.parks.models import Park
|
from apps.parks.models import Park
|
||||||
from apps.moderation.mixins import EditSubmissionMixin, HistoryMixin
|
from apps.moderation.mixins import EditSubmissionMixin, HistoryMixin
|
||||||
from apps.moderation.models import EditSubmission
|
from apps.moderation.models import EditSubmission
|
||||||
@@ -213,66 +215,81 @@ class RideUpdateView(
|
|||||||
|
|
||||||
|
|
||||||
class RideListView(ListView):
|
class RideListView(ListView):
|
||||||
"""View for displaying a list of rides"""
|
"""Enhanced view for displaying a list of rides with advanced filtering"""
|
||||||
|
|
||||||
model = Ride
|
model = Ride
|
||||||
template_name = "rides/ride_list.html"
|
template_name = "rides/ride_list.html"
|
||||||
context_object_name = "rides"
|
context_object_name = "rides"
|
||||||
|
paginate_by = 24
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Get filtered rides based on search and filters"""
|
"""Get filtered rides using the advanced search service"""
|
||||||
queryset = (
|
# Initialize search service
|
||||||
Ride.objects.all()
|
search_service = RideSearchService()
|
||||||
.select_related("park", "ride_model", "ride_model__manufacturer")
|
|
||||||
.prefetch_related("photos")
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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:
|
if "park_slug" in self.kwargs:
|
||||||
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
||||||
queryset = queryset.filter(park=self.park)
|
park = self.park
|
||||||
|
|
||||||
# Search term handling
|
if filter_form.is_valid():
|
||||||
search = self.request.GET.get("q", "").strip()
|
# Use advanced search service
|
||||||
if search:
|
queryset = search_service.search_rides(
|
||||||
# Split search terms for more flexible matching
|
filters=filter_form.get_filter_dict(), park=park
|
||||||
search_terms = search.split()
|
)
|
||||||
search_query = Q()
|
else:
|
||||||
|
# Fallback to basic queryset with park filter
|
||||||
for term in search_terms:
|
queryset = (
|
||||||
term_query = (
|
Ride.objects.all()
|
||||||
Q(name__icontains=term)
|
.select_related("park", "ride_model", "ride_model__manufacturer")
|
||||||
| Q(park__name__icontains=term)
|
.prefetch_related("photos")
|
||||||
| Q(description__icontains=term)
|
)
|
||||||
)
|
if park:
|
||||||
search_query &= term_query
|
queryset = queryset.filter(park=park)
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def get_template_names(self):
|
def get_template_names(self):
|
||||||
"""Return appropriate template based on request type"""
|
"""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 ["rides/partials/ride_list_results.html"]
|
||||||
return [self.template_name]
|
return [self.template_name]
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
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)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
# Add park context
|
||||||
if hasattr(self, "park"):
|
if hasattr(self, "park"):
|
||||||
context["park"] = self.park
|
context["park"] = self.park
|
||||||
context["park_slug"] = self.kwargs["park_slug"]
|
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
|
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
|
return context
|
||||||
|
|
||||||
|
|
||||||
@@ -457,7 +474,7 @@ class RideSearchView(ListView):
|
|||||||
|
|
||||||
|
|
||||||
class RideRankingsView(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
|
model = RideRanking
|
||||||
template_name = "rides/rankings.html"
|
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 -->
|
<!-- Results header with count and sorting -->
|
||||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6">
|
||||||
{% for ride in rides %}
|
<div class="mb-4 sm:mb-0">
|
||||||
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
<div class="aspect-w-16 aspect-h-9">
|
{% if filtered_count == total_rides %}
|
||||||
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
|
All {{ total_rides }} ride{{ total_rides|pluralize }}
|
||||||
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
|
{% else %}
|
||||||
{{ ride.name }}
|
{{ filtered_count }} of {{ total_rides }} ride{{ total_rides|pluralize }}
|
||||||
{% if ride.photos.exists %}
|
{% endif %}
|
||||||
<img src="{{ ride.photos.first.image.url }}"
|
{% if park %}
|
||||||
alt="{{ ride.name }}"
|
at {{ park.name }}
|
||||||
class="object-cover w-full">
|
{% endif %}
|
||||||
{% else %}
|
</h2>
|
||||||
<img src="{% get_ride_placeholder_image ride.category %}"
|
{% if query %}
|
||||||
alt="{{ ride.name }}"
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
class="object-cover w-full">
|
<i class="fas fa-search mr-1"></i>
|
||||||
{% endif %}
|
Searching for: "{{ query }}"
|
||||||
</a>
|
</p>
|
||||||
</div>
|
{% endif %}
|
||||||
<div class="p-4">
|
</div>
|
||||||
<h2 class="mb-2 text-xl font-bold">
|
|
||||||
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
|
<!-- Sort options -->
|
||||||
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
|
<div class="flex items-center space-x-2">
|
||||||
{{ ride.name }}
|
<label for="sort-select" class="text-sm font-medium text-gray-700 dark:text-gray-300">Sort by:</label>
|
||||||
</a>
|
<select id="sort-select"
|
||||||
</h2>
|
name="sort"
|
||||||
{% if not park %}
|
hx-get="{% url 'rides:ride_list' %}"
|
||||||
<p class="mb-3 text-gray-600 dark:text-gray-400">
|
hx-target="#filter-results"
|
||||||
at <a href="{% url 'parks:park_detail' ride.park.slug %}"
|
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 %}"
|
||||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
hx-push-url="true"
|
||||||
{{ ride.park.name }}
|
class="form-select text-sm">
|
||||||
</a>
|
<option value="name" {% if current_sort == 'name' %}selected{% endif %}>Name (A-Z)</option>
|
||||||
</p>
|
<option value="-name" {% if current_sort == '-name' %}selected{% endif %}>Name (Z-A)</option>
|
||||||
{% endif %}
|
<option value="-opened_date" {% if current_sort == '-opened_date' %}selected{% endif %}>Newest First</option>
|
||||||
<div class="flex flex-wrap gap-2">
|
<option value="opened_date" {% if current_sort == 'opened_date' %}selected{% endif %}>Oldest First</option>
|
||||||
<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">
|
<option value="-height" {% if current_sort == '-height' %}selected{% endif %}>Tallest First</option>
|
||||||
{{ ride.get_category_display }}
|
<option value="height" {% if current_sort == 'height' %}selected{% endif %}>Shortest First</option>
|
||||||
</span>
|
<option value="-max_speed" {% if current_sort == '-max_speed' %}selected{% endif %}>Fastest First</option>
|
||||||
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
|
<option value="max_speed" {% if current_sort == 'max_speed' %}selected{% endif %}>Slowest First</option>
|
||||||
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
|
<option value="-capacity_per_hour" {% if current_sort == '-capacity_per_hour' %}selected{% endif %}>Highest Capacity</option>
|
||||||
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
|
<option value="capacity_per_hour" {% if current_sort == 'capacity_per_hour' %}selected{% endif %}>Lowest Capacity</option>
|
||||||
{% elif ride.status == 'DEMOLISHED' %}status-demolished
|
<option value="relevance" {% if current_sort == 'relevance' %}selected{% endif %}>Most Relevant</option>
|
||||||
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
|
</select>
|
||||||
{{ ride.get_status_display }}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
{% 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">
|
<!-- Results grid -->
|
||||||
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
|
{% if rides %}
|
||||||
{{ ride.average_rating|floatformat:1 }}/10
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
</span>
|
{% for ride in rides %}
|
||||||
{% endif %}
|
<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">
|
||||||
</div>
|
<!-- Ride image -->
|
||||||
{% if ride.coaster_stats %}
|
<div class="relative h-48 bg-gradient-to-br from-blue-500 to-purple-600">
|
||||||
<div class="grid grid-cols-2 gap-2 mt-4">
|
{% if ride.image %}
|
||||||
{% if ride.coaster_stats.height_ft %}
|
<img src="{{ ride.image.url }}"
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
alt="{{ ride.name }}"
|
||||||
Height: {{ ride.coaster_stats.height_ft }}ft
|
class="w-full h-full object-cover">
|
||||||
</div>
|
{% else %}
|
||||||
{% endif %}
|
<div class="flex items-center justify-center h-full">
|
||||||
{% if ride.coaster_stats.speed_mph %}
|
<i class="fas fa-rocket text-4xl text-white opacity-50"></i>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Speed: {{ ride.coaster_stats.speed_mph }}mph
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% endif %}
|
||||||
<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>
|
<!-- 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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{% if is_paginated %}
|
{% if is_paginated %}
|
||||||
<div class="flex justify-center mt-6" hx-target="#ride-list-results" hx-push-url="false">
|
<div class="mt-8 flex items-center justify-between">
|
||||||
<div class="inline-flex rounded-md shadow-xs">
|
<div class="flex items-center text-sm text-gray-700 dark:text-gray-300">
|
||||||
{% if page_obj.has_previous %}
|
<p>
|
||||||
<a hx-get="?page=1{{ request.GET.urlencode }}"
|
Showing {{ page_obj.start_index }} to {{ page_obj.end_index }} of {{ filtered_count }} results
|
||||||
hx-target="#ride-list-results"
|
</p>
|
||||||
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>
|
</div>
|
||||||
<a hx-get="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}"
|
|
||||||
hx-target="#ride-list-results"
|
<div class="flex items-center space-x-2">
|
||||||
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>
|
{% if page_obj.has_previous %}
|
||||||
{% endif %}
|
<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 }}"
|
||||||
<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">
|
hx-target="#filter-results"
|
||||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
hx-push-url="true"
|
||||||
</span>
|
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>
|
||||||
{% if page_obj.has_next %}
|
Previous
|
||||||
<a hx-get="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}"
|
</a>
|
||||||
hx-target="#ride-list-results"
|
{% endif %}
|
||||||
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 }}"
|
<!-- Page numbers -->
|
||||||
hx-target="#ride-list-results"
|
<div class="hidden sm:flex items-center space-x-1">
|
||||||
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>
|
{% 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 %}
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</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>
|
</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[]
|
rides: Ride[]
|
||||||
}>('/api/rides/recent_relocations/', params)
|
}>('/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
|
updated: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search and filter types
|
// Search and filter types - Basic legacy interface (keeping for compatibility)
|
||||||
export interface SearchFilters {
|
export interface SearchFilters {
|
||||||
query?: string
|
query?: string
|
||||||
category?: string
|
category?: string
|
||||||
@@ -67,6 +67,26 @@ export interface SearchFilters {
|
|||||||
maxSpeed?: number
|
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 {
|
export interface ParkFilters {
|
||||||
query?: string
|
query?: string
|
||||||
status?: 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