Removed VueJS frontend and dramatically enhanced API

This commit is contained in:
pacnpal
2025-08-28 14:01:28 -04:00
parent 08a4a2d034
commit 715e284b3e
123 changed files with 4056 additions and 26546 deletions

649
api_endpoints_curl_commands.sh Executable file
View File

@@ -0,0 +1,649 @@
#!/bin/bash
# ThrillWiki API Endpoints - Complete Curl Commands
# Generated from comprehensive URL analysis
# Base URL - adjust as needed for your environment
BASE_URL="http://localhost:8000"
# Command line options
SKIP_AUTH=false
ONLY_AUTH=false
SKIP_DOCS=false
HELP=false
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--skip-auth)
SKIP_AUTH=true
shift
;;
--only-auth)
ONLY_AUTH=true
shift
;;
--skip-docs)
SKIP_DOCS=true
shift
;;
--base-url)
BASE_URL="$2"
shift 2
;;
--help|-h)
HELP=true
shift
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Show help
if [ "$HELP" = true ]; then
echo "ThrillWiki API Endpoints Test Suite"
echo ""
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --skip-auth Skip endpoints that require authentication"
echo " --only-auth Only test endpoints that require authentication"
echo " --skip-docs Skip API documentation endpoints (schema, swagger, redoc)"
echo " --base-url URL Set custom base URL (default: http://localhost:8000)"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $0 # Test all endpoints"
echo " $0 --skip-auth # Test only public endpoints"
echo " $0 --only-auth # Test only authenticated endpoints"
echo " $0 --skip-docs --skip-auth # Test only public non-documentation endpoints"
echo " $0 --base-url https://api.example.com # Use custom base URL"
exit 0
fi
# Validate conflicting options
if [ "$SKIP_AUTH" = true ] && [ "$ONLY_AUTH" = true ]; then
echo "Error: --skip-auth and --only-auth cannot be used together"
exit 1
fi
echo "=== ThrillWiki API Endpoints Test Suite ==="
echo "Base URL: $BASE_URL"
if [ "$SKIP_AUTH" = true ]; then
echo "Mode: Public endpoints only (skipping authentication required)"
elif [ "$ONLY_AUTH" = true ]; then
echo "Mode: Authenticated endpoints only"
else
echo "Mode: All endpoints"
fi
if [ "$SKIP_DOCS" = true ]; then
echo "Skipping: API documentation endpoints"
fi
echo ""
# Helper function to check if we should run an endpoint
should_run_endpoint() {
local requires_auth=$1
local is_docs=$2
# Skip docs if requested
if [ "$SKIP_DOCS" = true ] && [ "$is_docs" = true ]; then
return 1
fi
# Skip auth endpoints if requested
if [ "$SKIP_AUTH" = true ] && [ "$requires_auth" = true ]; then
return 1
fi
# Only run auth endpoints if requested
if [ "$ONLY_AUTH" = true ] && [ "$requires_auth" = false ]; then
return 1
fi
return 0
}
# Counter for endpoint numbering
ENDPOINT_NUM=1
# ============================================================================
# AUTHENTICATION ENDPOINTS (/api/v1/auth/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo "=== AUTHENTICATION ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. Login"
curl -X POST "$BASE_URL/api/v1/auth/login/" \
-H "Content-Type: application/json" \
-d '{"username": "testuser", "password": "testpass"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Signup"
curl -X POST "$BASE_URL/api/v1/auth/signup/" \
-H "Content-Type: application/json" \
-d '{"username": "newuser", "email": "test@example.com", "password": "newpass123"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Logout"
curl -X POST "$BASE_URL/api/v1/auth/logout/" \
-H "Content-Type: application/json"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Password Reset"
curl -X POST "$BASE_URL/api/v1/auth/password/reset/" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Social Providers"
curl -X GET "$BASE_URL/api/v1/auth/providers/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Auth Status"
curl -X GET "$BASE_URL/api/v1/auth/status/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Current User"
curl -X GET "$BASE_URL/api/v1/auth/user/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Password Change"
curl -X POST "$BASE_URL/api/v1/auth/password/change/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"old_password": "oldpass", "new_password": "newpass123"}'
((ENDPOINT_NUM++))
fi
# ============================================================================
# HEALTH CHECK ENDPOINTS (/api/v1/health/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== HEALTH CHECK ENDPOINTS ==="
echo "$ENDPOINT_NUM. Health Check"
curl -X GET "$BASE_URL/api/v1/health/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Simple Health"
curl -X GET "$BASE_URL/api/v1/health/simple/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Performance Metrics"
curl -X GET "$BASE_URL/api/v1/health/performance/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# TRENDING SYSTEM ENDPOINTS (/api/v1/trending/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== TRENDING SYSTEM ENDPOINTS ==="
echo "$ENDPOINT_NUM. Trending Content"
curl -X GET "$BASE_URL/api/v1/trending/content/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. New Content"
curl -X GET "$BASE_URL/api/v1/trending/new/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# STATISTICS ENDPOINTS (/api/v1/stats/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== STATISTICS ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. Statistics"
curl -X GET "$BASE_URL/api/v1/stats/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Recalculate Statistics"
curl -X POST "$BASE_URL/api/v1/stats/recalculate/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# RANKING SYSTEM ENDPOINTS (/api/v1/rankings/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== RANKING SYSTEM ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List Rankings"
curl -X GET "$BASE_URL/api/v1/rankings/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Rankings with Filters"
curl -X GET "$BASE_URL/api/v1/rankings/?category=RC&min_riders=10&ordering=rank"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking Detail"
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking History"
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/history/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking Statistics"
curl -X GET "$BASE_URL/api/v1/rankings/statistics/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking Comparisons"
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/comparisons/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Trigger Ranking Calculation"
curl -X POST "$BASE_URL/api/v1/rankings/calculate/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"category": "RC"}'
((ENDPOINT_NUM++))
fi
# ============================================================================
# PARKS API ENDPOINTS (/api/v1/parks/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== PARKS API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List Parks"
curl -X GET "$BASE_URL/api/v1/parks/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Filter Options"
curl -X GET "$BASE_URL/api/v1/parks/filter-options/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Company Search"
curl -X GET "$BASE_URL/api/v1/parks/search/companies/?q=disney"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Search Suggestions"
curl -X GET "$BASE_URL/api/v1/parks/search-suggestions/?q=magic"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Detail"
curl -X GET "$BASE_URL/api/v1/parks/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Park Photos"
curl -X GET "$BASE_URL/api/v1/parks/1/photos/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Photo Detail"
curl -X GET "$BASE_URL/api/v1/parks/1/photos/1/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Create Park"
curl -X POST "$BASE_URL/api/v1/parks/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Test Park", "location": "Test City"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Park"
curl -X PUT "$BASE_URL/api/v1/parks/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Updated Park Name"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Park"
curl -X DELETE "$BASE_URL/api/v1/parks/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Park Photo"
curl -X POST "$BASE_URL/api/v1/parks/1/photos/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-F "image=@/path/to/photo.jpg" \
-F "caption=Test photo"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Park Photo"
curl -X PUT "$BASE_URL/api/v1/parks/1/photos/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"caption": "Updated caption"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Park Photo"
curl -X DELETE "$BASE_URL/api/v1/parks/1/photos/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# RIDES API ENDPOINTS (/api/v1/rides/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== RIDES API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List Rides"
curl -X GET "$BASE_URL/api/v1/rides/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Filter Options"
curl -X GET "$BASE_URL/api/v1/rides/filter-options/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Company Search"
curl -X GET "$BASE_URL/api/v1/rides/search/companies/?q=intamin"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Model Search"
curl -X GET "$BASE_URL/api/v1/rides/search/ride-models/?q=giga"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Search Suggestions"
curl -X GET "$BASE_URL/api/v1/rides/search-suggestions/?q=millennium"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Detail"
curl -X GET "$BASE_URL/api/v1/rides/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Ride Photos"
curl -X GET "$BASE_URL/api/v1/rides/1/photos/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Photo Detail"
curl -X GET "$BASE_URL/api/v1/rides/1/photos/1/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Create Ride"
curl -X POST "$BASE_URL/api/v1/rides/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Test Coaster", "category": "RC", "park": 1}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Ride"
curl -X PUT "$BASE_URL/api/v1/rides/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Updated Ride Name"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Ride"
curl -X DELETE "$BASE_URL/api/v1/rides/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Ride Photo"
curl -X POST "$BASE_URL/api/v1/rides/1/photos/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-F "image=@/path/to/photo.jpg" \
-F "caption=Test ride photo"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Ride Photo"
curl -X PUT "$BASE_URL/api/v1/rides/1/photos/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"caption": "Updated ride photo caption"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Ride Photo"
curl -X DELETE "$BASE_URL/api/v1/rides/1/photos/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# ACCOUNTS API ENDPOINTS (/api/v1/accounts/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== ACCOUNTS API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List User Profiles"
curl -X GET "$BASE_URL/api/v1/accounts/profiles/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. User Profile Detail"
curl -X GET "$BASE_URL/api/v1/accounts/profiles/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Top Lists"
curl -X GET "$BASE_URL/api/v1/accounts/toplists/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Top List Detail"
curl -X GET "$BASE_URL/api/v1/accounts/toplists/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Top List Items"
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Top List Item Detail"
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/1/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Update User Profile"
curl -X PUT "$BASE_URL/api/v1/accounts/profiles/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"bio": "Updated bio"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Top List"
curl -X POST "$BASE_URL/api/v1/accounts/toplists/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "My Top Coasters", "description": "My favorite roller coasters"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Top List"
curl -X PUT "$BASE_URL/api/v1/accounts/toplists/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Updated Top List Name"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Top List"
curl -X DELETE "$BASE_URL/api/v1/accounts/toplists/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Top List Item"
curl -X POST "$BASE_URL/api/v1/accounts/toplist-items/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"toplist": 1, "ride": 1, "position": 1}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Top List Item"
curl -X PUT "$BASE_URL/api/v1/accounts/toplist-items/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"position": 2}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Top List Item"
curl -X DELETE "$BASE_URL/api/v1/accounts/toplist-items/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# HISTORY API ENDPOINTS (/api/v1/history/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== HISTORY API ENDPOINTS ==="
echo "$ENDPOINT_NUM. Park History List"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park History Detail"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/detail/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride History List"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride History Detail"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/detail/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Unified Timeline"
curl -X GET "$BASE_URL/api/v1/history/timeline/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Unified Timeline Detail"
curl -X GET "$BASE_URL/api/v1/history/timeline/1/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# EMAIL API ENDPOINTS (/api/v1/email/)
# ============================================================================
if should_run_endpoint true false; then
echo -e "\n\n=== EMAIL API ENDPOINTS ==="
echo "$ENDPOINT_NUM. Send Email"
curl -X POST "$BASE_URL/api/v1/email/send/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"to": "recipient@example.com", "subject": "Test", "message": "Test message"}'
((ENDPOINT_NUM++))
fi
# ============================================================================
# CORE API ENDPOINTS (/api/v1/core/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== CORE API ENDPOINTS ==="
echo "$ENDPOINT_NUM. Entity Fuzzy Search"
curl -X GET "$BASE_URL/api/v1/core/entities/search/?q=disney"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Entity Not Found"
curl -X POST "$BASE_URL/api/v1/core/entities/not-found/" \
-H "Content-Type: application/json" \
-d '{"query": "nonexistent park", "type": "park"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Entity Suggestions"
curl -X GET "$BASE_URL/api/v1/core/entities/suggestions/?q=magic"
((ENDPOINT_NUM++))
fi
# ============================================================================
# MAPS API ENDPOINTS (/api/v1/maps/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== MAPS API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. Map Locations"
curl -X GET "$BASE_URL/api/v1/maps/locations/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Location Detail"
curl -X GET "$BASE_URL/api/v1/maps/locations/park/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Search"
curl -X GET "$BASE_URL/api/v1/maps/search/?q=disney"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Bounds Query"
curl -X GET "$BASE_URL/api/v1/maps/bounds/?north=40.7&south=40.6&east=-73.9&west=-74.0"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Statistics"
curl -X GET "$BASE_URL/api/v1/maps/stats/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Cache Status"
curl -X GET "$BASE_URL/api/v1/maps/cache/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Invalidate Map Cache"
curl -X POST "$BASE_URL/api/v1/maps/cache/invalidate/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# API DOCUMENTATION ENDPOINTS
# ============================================================================
if should_run_endpoint false true; then
echo -e "\n\n=== API DOCUMENTATION ENDPOINTS ==="
echo "$ENDPOINT_NUM. OpenAPI Schema"
curl -X GET "$BASE_URL/api/schema/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Swagger UI"
curl -X GET "$BASE_URL/api/docs/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. ReDoc"
curl -X GET "$BASE_URL/api/redoc/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# HEALTH CHECK (Django Health Check)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== DJANGO HEALTH CHECK ==="
echo "$ENDPOINT_NUM. Django Health Check"
curl -X GET "$BASE_URL/health/"
((ENDPOINT_NUM++))
fi
echo -e "\n\n=== END OF API ENDPOINTS TEST SUITE ==="
echo "Total endpoints tested: $((ENDPOINT_NUM - 1))"
echo ""
echo "Notes:"
echo "- Replace YOUR_TOKEN_HERE with actual authentication tokens"
echo "- Replace /path/to/photo.jpg with actual file paths for photo uploads"
echo "- Replace numeric IDs (1, 2, etc.) with actual resource IDs"
echo "- Replace slug placeholders (park-slug, ride-slug) with actual slugs"
echo "- Adjust BASE_URL for your environment (localhost:8000, staging, production)"
echo ""
echo "Authentication required endpoints are marked with Authorization header"
echo "File upload endpoints use multipart/form-data (-F flag)"
echo "JSON endpoints use application/json content type"

View File

@@ -17,3 +17,7 @@ class ApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "api"
verbose_name = "ThrillWiki API"
def ready(self):
"""Import signals when the app is ready."""
import apps.api.v1.signals # noqa: F401

View File

@@ -4,8 +4,14 @@ Migrated from apps.core.views.map_views
"""
import logging
from typing import Dict, List, Any, Optional
from django.http import HttpRequest
from django.db.models import Q
from django.core.cache import cache
from django.contrib.gis.geos import Polygon
from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.geos import Point
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
@@ -13,6 +19,16 @@ from rest_framework.permissions import AllowAny
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from apps.parks.models import Park, ParkLocation
from apps.rides.models import Ride
from ..serializers.maps import (
MapLocationSerializer,
MapLocationsResponseSerializer,
MapSearchResultSerializer,
MapSearchResponseSerializer,
MapLocationDetailSerializer,
)
logger = logging.getLogger(__name__)
@@ -26,59 +42,71 @@ logger = logging.getLogger(__name__)
type=OpenApiTypes.NUMBER,
location=OpenApiParameter.QUERY,
required=False,
description="Northern latitude bound",
description="Northern latitude bound (-90 to 90). Used with south, east, west to define geographic bounds.",
examples=[41.5],
),
OpenApiParameter(
"south",
type=OpenApiTypes.NUMBER,
location=OpenApiParameter.QUERY,
required=False,
description="Southern latitude bound",
description="Southern latitude bound (-90 to 90). Must be less than north bound.",
examples=[41.4],
),
OpenApiParameter(
"east",
type=OpenApiTypes.NUMBER,
location=OpenApiParameter.QUERY,
required=False,
description="Eastern longitude bound",
description="Eastern longitude bound (-180 to 180). Must be greater than west bound.",
examples=[-82.6],
),
OpenApiParameter(
"west",
type=OpenApiTypes.NUMBER,
location=OpenApiParameter.QUERY,
required=False,
description="Western longitude bound",
description="Western longitude bound (-180 to 180). Used with other bounds for geographic filtering.",
examples=[-82.8],
),
OpenApiParameter(
"zoom",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=False,
description="Map zoom level",
description="Map zoom level (1-20). Higher values show more detail. Used for clustering decisions.",
examples=[10],
),
OpenApiParameter(
"types",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
description="Comma-separated location types",
description="Comma-separated location types to include. Valid values: 'park', 'ride'. Default: 'park,ride'",
examples=["park,ride", "park", "ride"],
),
OpenApiParameter(
"cluster",
type=OpenApiTypes.BOOL,
location=OpenApiParameter.QUERY,
required=False,
description="Enable clustering",
description="Enable location clustering for high-density areas. Default: false",
examples=[True, False],
),
OpenApiParameter(
"q",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
description="Text query",
description="Text search query. Searches park/ride names, cities, and states.",
examples=["Cedar Point", "roller coaster", "Ohio"],
),
],
responses={200: OpenApiTypes.OBJECT},
responses={
200: MapLocationsResponseSerializer,
400: OpenApiTypes.OBJECT,
500: OpenApiTypes.OBJECT,
},
tags=["Maps"],
),
)
@@ -90,15 +118,151 @@ class MapLocationsAPIView(APIView):
def get(self, request: HttpRequest) -> Response:
"""Get map locations with optional clustering and filtering."""
try:
# Simple implementation to fix import error
# TODO: Implement full functionality
return Response(
{
"status": "success",
"message": "Map locations endpoint - implementation needed",
"data": [],
}
)
# Parse query parameters
north = request.GET.get("north")
south = request.GET.get("south")
east = request.GET.get("east")
west = request.GET.get("west")
zoom = request.GET.get("zoom", 10)
types = request.GET.get("types", "park,ride").split(",")
cluster = request.GET.get("cluster", "false").lower() == "true"
query = request.GET.get("q", "").strip()
# Build cache key
cache_key = f"map_locations_{north}_{south}_{east}_{west}_{zoom}_{','.join(types)}_{cluster}_{query}"
cached_result = cache.get(cache_key)
if cached_result:
return Response(cached_result)
locations = []
total_count = 0
# Get parks if requested
if "park" in types:
parks_query = Park.objects.select_related("location", "operator").filter(
location__point__isnull=False
)
# Apply bounds filtering
if all([north, south, east, west]):
try:
bounds_polygon = Polygon.from_bbox((
float(west), float(south), float(east), float(north)
))
parks_query = parks_query.filter(
location__point__within=bounds_polygon)
except (ValueError, TypeError):
pass
# Apply text search
if query:
parks_query = parks_query.filter(
Q(name__icontains=query) |
Q(location__city__icontains=query) |
Q(location__state__icontains=query)
)
# Serialize parks
for park in parks_query[:100]: # Limit results
park_data = {
"id": park.id,
"type": "park",
"name": park.name,
"slug": park.slug,
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
"status": park.status,
"location": {
"city": park.location.city if hasattr(park, 'location') and park.location else "",
"state": park.location.state if hasattr(park, 'location') and park.location else "",
"country": park.location.country if hasattr(park, 'location') and park.location else "",
"formatted_address": park.location.formatted_address if hasattr(park, 'location') and park.location else "",
},
"stats": {
"coaster_count": park.coaster_count or 0,
"ride_count": park.ride_count or 0,
"average_rating": float(park.average_rating) if park.average_rating else None,
},
}
locations.append(park_data)
# Get rides if requested
if "ride" in types:
rides_query = Ride.objects.select_related("park__location", "manufacturer").filter(
park__location__point__isnull=False
)
# Apply bounds filtering
if all([north, south, east, west]):
try:
bounds_polygon = Polygon.from_bbox((
float(west), float(south), float(east), float(north)
))
rides_query = rides_query.filter(
park__location__point__within=bounds_polygon)
except (ValueError, TypeError):
pass
# Apply text search
if query:
rides_query = rides_query.filter(
Q(name__icontains=query) |
Q(park__name__icontains=query) |
Q(park__location__city__icontains=query)
)
# Serialize rides
for ride in rides_query[:100]: # Limit results
ride_data = {
"id": ride.id,
"type": "ride",
"name": ride.name,
"slug": ride.slug,
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
"status": ride.status,
"location": {
"city": ride.park.location.city if hasattr(ride.park, 'location') and ride.park.location else "",
"state": ride.park.location.state if hasattr(ride.park, 'location') and ride.park.location else "",
"country": ride.park.location.country if hasattr(ride.park, 'location') and ride.park.location else "",
"formatted_address": ride.park.location.formatted_address if hasattr(ride.park, 'location') and ride.park.location else "",
},
"stats": {
"category": ride.get_category_display() if ride.category else None,
"average_rating": float(ride.average_rating) if ride.average_rating else None,
"park_name": ride.park.name,
},
}
locations.append(ride_data)
total_count = len(locations)
# Calculate bounds from results
bounds = {}
if locations:
lats = [loc["latitude"] for loc in locations if loc["latitude"]]
lngs = [loc["longitude"] for loc in locations if loc["longitude"]]
if lats and lngs:
bounds = {
"north": max(lats),
"south": min(lats),
"east": max(lngs),
"west": min(lngs),
}
result = {
"status": "success",
"locations": locations,
"clusters": [], # TODO: Implement clustering
"bounds": bounds,
"total_count": total_count,
"clustered": cluster,
}
# Cache result for 5 minutes
cache.set(cache_key, result, 300)
return Response(result)
except Exception as e:
logger.error(f"Error in MapLocationsAPIView: {str(e)}", exc_info=True)
@@ -128,7 +292,12 @@ class MapLocationsAPIView(APIView):
description="ID of the location",
),
],
responses={200: OpenApiTypes.OBJECT, 404: OpenApiTypes.OBJECT},
responses={
200: MapLocationDetailSerializer,
400: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
500: OpenApiTypes.OBJECT,
},
tags=["Maps"],
),
)
@@ -142,17 +311,90 @@ class MapLocationDetailAPIView(APIView):
) -> Response:
"""Get detailed information for a specific location."""
try:
# Simple implementation to fix import error
return Response(
{
"status": "success",
"message": f"Location detail for {location_type}/{location_id} - implementation needed",
"data": {
"location_type": location_type,
"location_id": location_id,
if location_type == "park":
try:
obj = Park.objects.select_related(
"location", "operator").get(id=location_id)
except Park.DoesNotExist:
return Response(
{"status": "error", "message": "Park not found"},
status=status.HTTP_404_NOT_FOUND,
)
elif location_type == "ride":
try:
obj = Ride.objects.select_related(
"park__location", "manufacturer").get(id=location_id)
except Ride.DoesNotExist:
return Response(
{"status": "error", "message": "Ride not found"},
status=status.HTTP_404_NOT_FOUND,
)
else:
return Response(
{"status": "error", "message": "Invalid location type"},
status=status.HTTP_400_BAD_REQUEST,
)
# Serialize the object
if location_type == "park":
data = {
"id": obj.id,
"type": "park",
"name": obj.name,
"slug": obj.slug,
"description": obj.description,
"latitude": obj.location.latitude if hasattr(obj, 'location') and obj.location else None,
"longitude": obj.location.longitude if hasattr(obj, 'location') and obj.location else None,
"status": obj.status,
"location": {
"street_address": obj.location.street_address if hasattr(obj, 'location') and obj.location else "",
"city": obj.location.city if hasattr(obj, 'location') and obj.location else "",
"state": obj.location.state if hasattr(obj, 'location') and obj.location else "",
"country": obj.location.country if hasattr(obj, 'location') and obj.location else "",
"postal_code": obj.location.postal_code if hasattr(obj, 'location') and obj.location else "",
"formatted_address": obj.location.formatted_address if hasattr(obj, 'location') and obj.location else "",
},
"stats": {
"coaster_count": obj.coaster_count or 0,
"ride_count": obj.ride_count or 0,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"size_acres": float(obj.size_acres) if obj.size_acres else None,
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
},
"nearby_locations": [], # TODO: Implement nearby locations
}
)
else: # ride
data = {
"id": obj.id,
"type": "ride",
"name": obj.name,
"slug": obj.slug,
"description": obj.description,
"latitude": obj.park.location.latitude if hasattr(obj.park, 'location') and obj.park.location else None,
"longitude": obj.park.location.longitude if hasattr(obj.park, 'location') and obj.park.location else None,
"status": obj.status,
"location": {
"street_address": obj.park.location.street_address if hasattr(obj.park, 'location') and obj.park.location else "",
"city": obj.park.location.city if hasattr(obj.park, 'location') and obj.park.location else "",
"state": obj.park.location.state if hasattr(obj.park, 'location') and obj.park.location else "",
"country": obj.park.location.country if hasattr(obj.park, 'location') and obj.park.location else "",
"postal_code": obj.park.location.postal_code if hasattr(obj.park, 'location') and obj.park.location else "",
"formatted_address": obj.park.location.formatted_address if hasattr(obj.park, 'location') and obj.park.location else "",
},
"stats": {
"category": obj.get_category_display() if obj.category else None,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"park_name": obj.park.name,
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
"manufacturer": obj.manufacturer.name if obj.manufacturer else None,
},
"nearby_locations": [], # TODO: Implement nearby locations
}
return Response({
"status": "success",
"data": data,
})
except Exception as e:
logger.error(f"Error in MapLocationDetailAPIView: {str(e)}", exc_info=True)
@@ -174,8 +416,33 @@ class MapLocationDetailAPIView(APIView):
required=True,
description="Search query",
),
OpenApiParameter(
"types",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
description="Comma-separated location types (park,ride)",
),
OpenApiParameter(
"page",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=False,
description="Page number",
),
OpenApiParameter(
"page_size",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=False,
description="Results per page",
),
],
responses={200: OpenApiTypes.OBJECT, 400: OpenApiTypes.OBJECT},
responses={
200: MapSearchResponseSerializer,
400: OpenApiTypes.OBJECT,
500: OpenApiTypes.OBJECT,
},
tags=["Maps"],
),
)
@@ -197,14 +464,76 @@ class MapSearchAPIView(APIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Simple implementation to fix import error
return Response(
{
"status": "success",
"message": f"Search for '{query}' - implementation needed",
"data": [],
}
)
types = request.GET.get("types", "park,ride").split(",")
page = int(request.GET.get("page", 1))
page_size = min(int(request.GET.get("page_size", 20)), 100)
results = []
total_count = 0
# Search parks
if "park" in types:
parks_query = Park.objects.select_related("location").filter(
Q(name__icontains=query) |
Q(location__city__icontains=query) |
Q(location__state__icontains=query)
).filter(location__point__isnull=False)
for park in parks_query[:50]: # Limit results
results.append({
"id": park.id,
"type": "park",
"name": park.name,
"slug": park.slug,
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
"location": {
"city": park.location.city if hasattr(park, 'location') and park.location else "",
"state": park.location.state if hasattr(park, 'location') and park.location else "",
"country": park.location.country if hasattr(park, 'location') and park.location else "",
},
"relevance_score": 1.0, # TODO: Implement relevance scoring
})
# Search rides
if "ride" in types:
rides_query = Ride.objects.select_related("park__location").filter(
Q(name__icontains=query) |
Q(park__name__icontains=query) |
Q(park__location__city__icontains=query)
).filter(park__location__point__isnull=False)
for ride in rides_query[:50]: # Limit results
results.append({
"id": ride.id,
"type": "ride",
"name": ride.name,
"slug": ride.slug,
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
"location": {
"city": ride.park.location.city if hasattr(ride.park, 'location') and ride.park.location else "",
"state": ride.park.location.state if hasattr(ride.park, 'location') and ride.park.location else "",
"country": ride.park.location.country if hasattr(ride.park, 'location') and ride.park.location else "",
},
"relevance_score": 1.0, # TODO: Implement relevance scoring
})
total_count = len(results)
# Apply pagination
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_results = results[start_idx:end_idx]
return Response({
"status": "success",
"results": paginated_results,
"query": query,
"total_count": total_count,
"page": page,
"page_size": page_size,
})
except Exception as e:
logger.error(f"Error in MapSearchAPIView: {str(e)}", exc_info=True)
@@ -247,6 +576,13 @@ class MapSearchAPIView(APIView):
required=True,
description="Western longitude bound",
),
OpenApiParameter(
"types",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
description="Comma-separated location types (park,ride)",
),
],
responses={200: OpenApiTypes.OBJECT, 400: OpenApiTypes.OBJECT},
tags=["Maps"],
@@ -260,22 +596,87 @@ class MapBoundsAPIView(APIView):
def get(self, request: HttpRequest) -> Response:
"""Get locations within specific geographic bounds."""
try:
# Simple implementation to fix import error
return Response(
{
"status": "success",
"message": "Bounds query - implementation needed",
"data": [],
}
)
# Parse required bounds parameters
try:
north = float(request.GET.get("north"))
south = float(request.GET.get("south"))
east = float(request.GET.get("east"))
west = float(request.GET.get("west"))
except (TypeError, ValueError):
return Response(
{"status": "error", "message": "Invalid bounds parameters"},
status=status.HTTP_400_BAD_REQUEST,
)
# Validate bounds
if north <= south:
return Response(
{"status": "error", "message": "North bound must be greater than south bound"},
status=status.HTTP_400_BAD_REQUEST,
)
if west >= east:
return Response(
{"status": "error", "message": "West bound must be less than east bound"},
status=status.HTTP_400_BAD_REQUEST,
)
types = request.GET.get("types", "park,ride").split(",")
locations = []
# Create bounds polygon
bounds_polygon = Polygon.from_bbox((west, south, east, north))
# Get parks within bounds
if "park" in types:
parks_query = Park.objects.select_related("location").filter(
location__point__within=bounds_polygon
)
for park in parks_query[:100]: # Limit results
locations.append({
"id": park.id,
"type": "park",
"name": park.name,
"slug": park.slug,
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
"status": park.status,
})
# Get rides within bounds
if "ride" in types:
rides_query = Ride.objects.select_related("park__location").filter(
park__location__point__within=bounds_polygon
)
for ride in rides_query[:100]: # Limit results
locations.append({
"id": ride.id,
"type": "ride",
"name": ride.name,
"slug": ride.slug,
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
"status": ride.status,
})
return Response({
"status": "success",
"locations": locations,
"bounds": {
"north": north,
"south": south,
"east": east,
"west": west,
},
"total_count": len(locations),
})
except Exception as e:
logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True)
return Response(
{
"status": "error",
"message": "Failed to retrieve locations within bounds",
},
{"status": "error", "message": "Failed to retrieve locations within bounds"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -296,15 +697,26 @@ class MapStatsAPIView(APIView):
def get(self, request: HttpRequest) -> Response:
"""Get map service statistics and performance metrics."""
try:
# Simple implementation to fix import error
return Response(
{
"status": "success",
"data": {"total_locations": 0, "cache_hits": 0, "cache_misses": 0},
}
)
# Count locations with coordinates
parks_with_location = Park.objects.filter(
location__point__isnull=False).count()
rides_with_location = Ride.objects.filter(
park__location__point__isnull=False).count()
total_locations = parks_with_location + rides_with_location
return Response({
"status": "success",
"data": {
"total_locations": total_locations,
"parks_with_location": parks_with_location,
"rides_with_location": rides_with_location,
"cache_hits": 0, # TODO: Implement cache statistics
"cache_misses": 0, # TODO: Implement cache statistics
},
})
except Exception as e:
logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True)
return Response(
{"error": f"Internal server error: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -333,12 +745,21 @@ class MapCacheAPIView(APIView):
def delete(self, request: HttpRequest) -> Response:
"""Clear all map cache (admin only)."""
try:
# Simple implementation to fix import error
return Response(
{"status": "success", "message": "Map cache cleared successfully"}
)
# Clear all map-related cache keys
cache_keys = cache.keys("map_*")
if cache_keys:
cache.delete_many(cache_keys)
cleared_count = len(cache_keys)
else:
cleared_count = 0
return Response({
"status": "success",
"message": f"Map cache cleared successfully. Cleared {cleared_count} entries.",
})
except Exception as e:
logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True)
return Response(
{"error": f"Internal server error: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -347,12 +768,21 @@ class MapCacheAPIView(APIView):
def post(self, request: HttpRequest) -> Response:
"""Invalidate specific cache entries."""
try:
# Simple implementation to fix import error
return Response(
{"status": "success", "message": "Cache invalidated successfully"}
)
# Get cache keys to invalidate from request data
cache_keys = request.data.get("cache_keys", [])
if cache_keys:
cache.delete_many(cache_keys)
invalidated_count = len(cache_keys)
else:
invalidated_count = 0
return Response({
"status": "success",
"message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.",
})
except Exception as e:
logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True)
return Response(
{"error": f"Internal server error: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@@ -146,9 +146,10 @@ def _import_accounts_symbols() -> Dict[str, Any]:
_accounts = _import_accounts_symbols()
# Bind account symbols into the module namespace (either actual objects or None)
# Bind account symbols into the module namespace (only if they exist)
for _name in _ACCOUNTS_SYMBOLS:
globals()[_name] = _accounts.get(_name)
if _accounts.get(_name) is not None:
globals()[_name] = _accounts[_name]
# --- Services domain ---
@@ -255,22 +256,79 @@ _SERVICES_EXPORTS = [
"DistanceCalculationOutputSerializer",
]
# Build __all__ from known exports plus any serializer-like names discovered above
__all__ = (
_SHARED_EXPORTS
+ _PARKS_EXPORTS
+ _COMPANIES_EXPORTS
+ _RIDES_EXPORTS
+ _SERVICES_EXPORTS
+ _ACCOUNTS_SYMBOLS
)
# Build a static __all__ list with only the serializers we know exist
__all__ = [
# Shared exports
"CATEGORY_CHOICES",
"ModelChoices",
"LocationOutputSerializer",
"CompanyOutputSerializer",
"UserModel",
# Add any discovered globals that look like serializers (avoid duplicates)
for name in list(globals().keys()):
if name in __all__:
continue
if name.endswith(("Serializer", "OutputSerializer", "InputSerializer")):
# Parks exports
"ParkListOutputSerializer",
"ParkDetailOutputSerializer",
"ParkCreateInputSerializer",
"ParkUpdateInputSerializer",
"ParkFilterInputSerializer",
"ParkAreaDetailOutputSerializer",
"ParkAreaCreateInputSerializer",
"ParkAreaUpdateInputSerializer",
"ParkLocationOutputSerializer",
"ParkLocationCreateInputSerializer",
"ParkLocationUpdateInputSerializer",
"ParkSuggestionSerializer",
"ParkSuggestionOutputSerializer",
# Companies exports
"CompanyDetailOutputSerializer",
"CompanyCreateInputSerializer",
"CompanyUpdateInputSerializer",
"RideModelDetailOutputSerializer",
"RideModelCreateInputSerializer",
"RideModelUpdateInputSerializer",
# Rides exports
"RideParkOutputSerializer",
"RideModelOutputSerializer",
"RideListOutputSerializer",
"RideDetailOutputSerializer",
"RideCreateInputSerializer",
"RideUpdateInputSerializer",
"RideFilterInputSerializer",
"RollerCoasterStatsOutputSerializer",
"RollerCoasterStatsCreateInputSerializer",
"RollerCoasterStatsUpdateInputSerializer",
"RideLocationOutputSerializer",
"RideLocationCreateInputSerializer",
"RideLocationUpdateInputSerializer",
"RideReviewOutputSerializer",
"RideReviewCreateInputSerializer",
"RideReviewUpdateInputSerializer",
# Services exports
"HealthCheckOutputSerializer",
"PerformanceMetricsOutputSerializer",
"SimpleHealthOutputSerializer",
"EmailSendInputSerializer",
"EmailTemplateOutputSerializer",
"MapDataOutputSerializer",
"CoordinateInputSerializer",
"HistoryEventSerializer",
"HistoryEntryOutputSerializer",
"HistoryCreateInputSerializer",
"ModerationSubmissionSerializer",
"ModerationSubmissionOutputSerializer",
"RoadtripParkSerializer",
"RoadtripCreateInputSerializer",
"RoadtripOutputSerializer",
"GeocodeInputSerializer",
"GeocodeOutputSerializer",
"DistanceCalculationInputSerializer",
"DistanceCalculationOutputSerializer",
]
# Add any accounts serializers that actually exist
for name in _ACCOUNTS_SYMBOLS:
if name in globals():
__all__.append(name)
# Ensure __all__ is a flat list of unique strings (preserve order)
__all__ = list(dict.fromkeys(__all__))

View File

@@ -0,0 +1,408 @@
"""
Maps domain serializers for ThrillWiki API v1.
This module contains all serializers related to map functionality,
including location data, search results, and clustering.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
)
# === MAP LOCATION SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Map Location Example",
summary="Example map location response",
description="A location point on the map",
value={
"id": 1,
"type": "park",
"name": "Cedar Point",
"slug": "cedar-point",
"latitude": 41.4793,
"longitude": -82.6833,
"status": "OPERATING",
"location": {
"city": "Sandusky",
"state": "Ohio",
"country": "United States",
},
"stats": {
"coaster_count": 17,
"ride_count": 70,
"average_rating": 4.5,
},
},
)
]
)
class MapLocationSerializer(serializers.Serializer):
"""Serializer for individual map locations (parks and rides)."""
id = serializers.IntegerField()
type = serializers.CharField() # 'park' or 'ride'
name = serializers.CharField()
slug = serializers.CharField()
latitude = serializers.FloatField(allow_null=True)
longitude = serializers.FloatField(allow_null=True)
status = serializers.CharField()
# Location details
location = serializers.SerializerMethodField()
# Statistics
stats = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_location(self, obj) -> dict:
"""Get location information."""
if hasattr(obj, 'location') and obj.location:
return {
"city": obj.location.city,
"state": obj.location.state,
"country": obj.location.country,
"formatted_address": obj.location.formatted_address,
}
return {}
@extend_schema_field(serializers.DictField())
def get_stats(self, obj) -> dict:
"""Get relevant statistics based on object type."""
if obj._meta.model_name == 'park':
return {
"coaster_count": obj.coaster_count or 0,
"ride_count": obj.ride_count or 0,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
}
elif obj._meta.model_name == 'ride':
return {
"category": obj.get_category_display() if obj.category else None,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"park_name": obj.park.name if obj.park else None,
}
return {}
@extend_schema_serializer(
examples=[
OpenApiExample(
"Map Cluster Example",
summary="Example map cluster response",
description="A cluster of locations on the map",
value={
"id": "cluster_1",
"type": "cluster",
"latitude": 41.5,
"longitude": -82.7,
"count": 5,
"bounds": {
"north": 41.6,
"south": 41.4,
"east": -82.6,
"west": -82.8,
},
},
)
]
)
class MapClusterSerializer(serializers.Serializer):
"""Serializer for map clusters."""
id = serializers.CharField()
type = serializers.CharField(default="cluster")
latitude = serializers.FloatField()
longitude = serializers.FloatField()
count = serializers.IntegerField()
bounds = serializers.DictField()
@extend_schema_serializer(
examples=[
OpenApiExample(
"Map Locations Response Example",
summary="Example map locations response",
description="Response containing locations and optional clusters",
value={
"status": "success",
"data": {
"locations": [
{
"id": 1,
"type": "park",
"name": "Cedar Point",
"slug": "cedar-point",
"latitude": 41.4793,
"longitude": -82.6833,
"status": "OPERATING",
}
],
"clusters": [],
"bounds": {
"north": 41.5,
"south": 41.4,
"east": -82.6,
"west": -82.8,
},
"total_count": 1,
"clustered": False,
},
},
)
]
)
class MapLocationsResponseSerializer(serializers.Serializer):
"""Response serializer for map locations endpoint."""
status = serializers.CharField(default="success")
locations = serializers.ListField(child=serializers.DictField())
clusters = serializers.ListField(child=serializers.DictField(), default=list)
bounds = serializers.DictField(default=dict)
total_count = serializers.IntegerField(default=0)
clustered = serializers.BooleanField(default=False)
# === MAP SEARCH SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Map Search Result Example",
summary="Example map search result",
description="A search result for map locations",
value={
"id": 1,
"type": "park",
"name": "Cedar Point",
"slug": "cedar-point",
"latitude": 41.4793,
"longitude": -82.6833,
"location": {
"city": "Sandusky",
"state": "Ohio",
"country": "United States",
},
"relevance_score": 0.95,
},
)
]
)
class MapSearchResultSerializer(serializers.Serializer):
"""Serializer for map search results."""
id = serializers.IntegerField()
type = serializers.CharField()
name = serializers.CharField()
slug = serializers.CharField()
latitude = serializers.FloatField(allow_null=True)
longitude = serializers.FloatField(allow_null=True)
location = serializers.SerializerMethodField()
relevance_score = serializers.FloatField(required=False)
@extend_schema_field(serializers.DictField())
def get_location(self, obj) -> dict:
"""Get location information."""
if hasattr(obj, 'location') and obj.location:
return {
"city": obj.location.city,
"state": obj.location.state,
"country": obj.location.country,
}
return {}
@extend_schema_serializer(
examples=[
OpenApiExample(
"Map Search Response Example",
summary="Example map search response",
description="Response containing search results",
value={
"status": "success",
"data": {
"results": [
{
"id": 1,
"type": "park",
"name": "Cedar Point",
"slug": "cedar-point",
"latitude": 41.4793,
"longitude": -82.6833,
}
],
"query": "cedar point",
"total_count": 1,
"page": 1,
"page_size": 20,
},
},
)
]
)
class MapSearchResponseSerializer(serializers.Serializer):
"""Response serializer for map search endpoint."""
status = serializers.CharField(default="success")
results = serializers.ListField(child=serializers.DictField())
query = serializers.CharField()
total_count = serializers.IntegerField(default=0)
page = serializers.IntegerField(default=1)
page_size = serializers.IntegerField(default=20)
# === MAP DETAIL SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Map Location Detail Example",
summary="Example map location detail response",
description="Detailed information about a specific location",
value={
"id": 1,
"type": "park",
"name": "Cedar Point",
"slug": "cedar-point",
"description": "America's Roller Coast",
"latitude": 41.4793,
"longitude": -82.6833,
"status": "OPERATING",
"location": {
"street_address": "1 Cedar Point Dr",
"city": "Sandusky",
"state": "Ohio",
"country": "United States",
"postal_code": "44870",
"formatted_address": "1 Cedar Point Dr, Sandusky, Ohio, 44870, United States",
},
"stats": {
"coaster_count": 17,
"ride_count": 70,
"average_rating": 4.5,
},
"nearby_locations": [],
},
)
]
)
class MapLocationDetailSerializer(serializers.Serializer):
"""Serializer for detailed map location information."""
id = serializers.IntegerField()
type = serializers.CharField()
name = serializers.CharField()
slug = serializers.CharField()
description = serializers.CharField()
latitude = serializers.FloatField(allow_null=True)
longitude = serializers.FloatField(allow_null=True)
status = serializers.CharField()
# Detailed location information
location = serializers.SerializerMethodField()
# Statistics
stats = serializers.SerializerMethodField()
# Nearby locations
nearby_locations = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_location(self, obj) -> dict:
"""Get detailed location information."""
if hasattr(obj, 'location') and obj.location:
return {
"street_address": obj.location.street_address,
"city": obj.location.city,
"state": obj.location.state,
"country": obj.location.country,
"postal_code": obj.location.postal_code,
"formatted_address": obj.location.formatted_address,
}
return {}
@extend_schema_field(serializers.DictField())
def get_stats(self, obj) -> dict:
"""Get detailed statistics based on object type."""
if obj._meta.model_name == 'park':
return {
"coaster_count": obj.coaster_count or 0,
"ride_count": obj.ride_count or 0,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"size_acres": float(obj.size_acres) if obj.size_acres else None,
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
}
elif obj._meta.model_name == 'ride':
return {
"category": obj.get_category_display() if obj.category else None,
"average_rating": float(obj.average_rating) if obj.average_rating else None,
"park_name": obj.park.name if obj.park else None,
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
"manufacturer": obj.manufacturer.name if obj.manufacturer else None,
}
return {}
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_nearby_locations(self, obj) -> list:
"""Get nearby locations (placeholder for now)."""
# TODO: Implement nearby location logic
return []
# === INPUT SERIALIZERS ===
class MapBoundsInputSerializer(serializers.Serializer):
"""Input serializer for map bounds queries."""
north = serializers.FloatField(min_value=-90, max_value=90)
south = serializers.FloatField(min_value=-90, max_value=90)
east = serializers.FloatField(min_value=-180, max_value=180)
west = serializers.FloatField(min_value=-180, max_value=180)
def validate(self, attrs):
"""Validate that bounds make geographic sense."""
if attrs['north'] <= attrs['south']:
raise serializers.ValidationError(
"North bound must be greater than south bound")
# Handle longitude wraparound (e.g., crossing the international date line)
# For now, we'll require west < east for simplicity
if attrs['west'] >= attrs['east']:
raise serializers.ValidationError("West bound must be less than east bound")
return attrs
class MapSearchInputSerializer(serializers.Serializer):
"""Input serializer for map search queries."""
q = serializers.CharField(min_length=1, max_length=255)
types = serializers.CharField(required=False, allow_blank=True)
bounds = MapBoundsInputSerializer(required=False)
page = serializers.IntegerField(min_value=1, default=1)
page_size = serializers.IntegerField(min_value=1, max_value=100, default=20)
def validate_types(self, value):
"""Validate location types."""
if not value:
return []
valid_types = ['park', 'ride']
types = [t.strip().lower() for t in value.split(',')]
for location_type in types:
if location_type not in valid_types:
raise serializers.ValidationError(
f"Invalid location type: {location_type}. Valid types: {', '.join(valid_types)}"
)
return types

View File

@@ -0,0 +1,155 @@
"""
Statistics serializers for ThrillWiki API.
Provides serialization for platform statistics data.
"""
from rest_framework import serializers
class StatsSerializer(serializers.Serializer):
"""
Serializer for platform statistics response.
This serializer defines the structure of the statistics API response,
including all the various counts and breakdowns available.
"""
# Core entity counts
total_parks = serializers.IntegerField(
help_text="Total number of parks in the database"
)
total_rides = serializers.IntegerField(
help_text="Total number of rides in the database"
)
total_manufacturers = serializers.IntegerField(
help_text="Total number of ride manufacturers"
)
total_operators = serializers.IntegerField(
help_text="Total number of park operators"
)
total_designers = serializers.IntegerField(
help_text="Total number of ride designers"
)
total_property_owners = serializers.IntegerField(
help_text="Total number of property owners"
)
total_roller_coasters = serializers.IntegerField(
help_text="Total number of roller coasters with detailed stats"
)
# Photo counts
total_photos = serializers.IntegerField(
help_text="Total number of photos (parks + rides combined)"
)
total_park_photos = serializers.IntegerField(
help_text="Total number of park photos"
)
total_ride_photos = serializers.IntegerField(
help_text="Total number of ride photos"
)
# Review counts
total_reviews = serializers.IntegerField(
help_text="Total number of reviews (parks + rides)"
)
total_park_reviews = serializers.IntegerField(
help_text="Total number of park reviews"
)
total_ride_reviews = serializers.IntegerField(
help_text="Total number of ride reviews"
)
# Ride category counts (optional fields since they depend on data)
roller_coasters = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as roller coasters"
)
dark_rides = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as dark rides"
)
flat_rides = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as flat rides"
)
water_rides = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as water rides"
)
transport_rides = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as transport rides"
)
other_rides = serializers.IntegerField(
required=False,
help_text="Number of rides categorized as other"
)
# Park status counts (optional fields since they depend on data)
operating_parks = serializers.IntegerField(
required=False,
help_text="Number of currently operating parks"
)
temporarily_closed_parks = serializers.IntegerField(
required=False,
help_text="Number of temporarily closed parks"
)
permanently_closed_parks = serializers.IntegerField(
required=False,
help_text="Number of permanently closed parks"
)
under_construction_parks = serializers.IntegerField(
required=False,
help_text="Number of parks under construction"
)
demolished_parks = serializers.IntegerField(
required=False,
help_text="Number of demolished parks"
)
relocated_parks = serializers.IntegerField(
required=False,
help_text="Number of relocated parks"
)
# Ride status counts (optional fields since they depend on data)
operating_rides = serializers.IntegerField(
required=False,
help_text="Number of currently operating rides"
)
temporarily_closed_rides = serializers.IntegerField(
required=False,
help_text="Number of temporarily closed rides"
)
sbno_rides = serializers.IntegerField(
required=False,
help_text="Number of rides standing but not operating"
)
closing_rides = serializers.IntegerField(
required=False,
help_text="Number of rides in the process of closing"
)
permanently_closed_rides = serializers.IntegerField(
required=False,
help_text="Number of permanently closed rides"
)
under_construction_rides = serializers.IntegerField(
required=False,
help_text="Number of rides under construction"
)
demolished_rides = serializers.IntegerField(
required=False,
help_text="Number of demolished rides"
)
relocated_rides = serializers.IntegerField(
required=False,
help_text="Number of relocated rides"
)
# Metadata
last_updated = serializers.CharField(
help_text="ISO timestamp when these statistics were last calculated"
)
relative_last_updated = serializers.CharField(
help_text="Human-readable relative time since last update (e.g., '2 minutes ago')"
)

View File

@@ -0,0 +1,95 @@
"""
Django signals for automatically updating statistics cache.
This module contains signal handlers that invalidate the stats cache
whenever relevant entities are created, updated, or deleted.
"""
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.core.cache import cache
from apps.parks.models import Park, ParkReview, ParkPhoto, Company as ParkCompany
from apps.rides.models import Ride, RollerCoasterStats, RideReview, RidePhoto, Company as RideCompany
def invalidate_stats_cache():
"""
Invalidate the platform stats cache.
This function is called whenever any entity that affects statistics
is created, updated, or deleted.
"""
cache.delete("platform_stats")
# Also update the timestamp for when stats were last invalidated
from datetime import datetime
cache.set("platform_stats_timestamp", datetime.now().isoformat(), 300)
# Park signals
@receiver(post_save, sender=Park)
@receiver(post_delete, sender=Park)
def park_changed(sender, **kwargs):
"""Handle Park creation/deletion."""
invalidate_stats_cache()
# Ride signals
@receiver(post_save, sender=Ride)
@receiver(post_delete, sender=Ride)
def ride_changed(sender, **kwargs):
"""Handle Ride creation/deletion."""
invalidate_stats_cache()
# Roller coaster stats signals
@receiver(post_save, sender=RollerCoasterStats)
@receiver(post_delete, sender=RollerCoasterStats)
def roller_coaster_stats_changed(sender, **kwargs):
"""Handle RollerCoasterStats creation/deletion."""
invalidate_stats_cache()
# Company signals (both park and ride companies)
@receiver(post_save, sender=ParkCompany)
@receiver(post_delete, sender=ParkCompany)
def park_company_changed(sender, **kwargs):
"""Handle ParkCompany creation/deletion."""
invalidate_stats_cache()
@receiver(post_save, sender=RideCompany)
@receiver(post_delete, sender=RideCompany)
def ride_company_changed(sender, **kwargs):
"""Handle RideCompany creation/deletion."""
invalidate_stats_cache()
# Photo signals
@receiver(post_save, sender=ParkPhoto)
@receiver(post_delete, sender=ParkPhoto)
def park_photo_changed(sender, **kwargs):
"""Handle ParkPhoto creation/deletion."""
invalidate_stats_cache()
@receiver(post_save, sender=RidePhoto)
@receiver(post_delete, sender=RidePhoto)
def ride_photo_changed(sender, **kwargs):
"""Handle RidePhoto creation/deletion."""
invalidate_stats_cache()
# Review signals
@receiver(post_save, sender=ParkReview)
@receiver(post_delete, sender=ParkReview)
def park_review_changed(sender, **kwargs):
"""Handle ParkReview creation/deletion."""
invalidate_stats_cache()
@receiver(post_save, sender=RideReview)
@receiver(post_delete, sender=RideReview)
def ride_review_changed(sender, **kwargs):
"""Handle RideReview creation/deletion."""
invalidate_stats_cache()

View File

@@ -22,6 +22,7 @@ from .views import (
TrendingAPIView,
NewContentAPIView,
)
from .views.stats import StatsAPIView, StatsRecalculateAPIView
from django.urls import path, include
from rest_framework.routers import DefaultRouter
@@ -58,6 +59,9 @@ urlpatterns = [
# Trending system endpoints
path("trending/content/", TrendingAPIView.as_view(), name="trending"),
path("trending/new/", NewContentAPIView.as_view(), name="new-content"),
# Statistics endpoints
path("stats/", StatsAPIView.as_view(), name="stats"),
path("stats/recalculate/", StatsRecalculateAPIView.as_view(), name="stats-recalculate"),
# Ranking system endpoints
path(
"rankings/calculate/",

View File

@@ -301,56 +301,77 @@ class SocialProvidersAPIView(APIView):
def get(self, request: Request) -> Response:
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist
site = get_current_site(request._request) # type: ignore[attr-defined]
try:
site = get_current_site(request._request) # type: ignore[attr-defined]
# Cache key based on site and request host
# Use pk for Site objects, domain for RequestSite objects
site_identifier = getattr(site, "pk", site.domain)
cache_key = f"social_providers:{site_identifier}:{request.get_host()}"
# Cache key based on site and request host
# Use pk for Site objects, domain for RequestSite objects
site_identifier = getattr(site, "pk", site.domain)
cache_key = f"social_providers:{site_identifier}:{request.get_host()}"
# Try to get from cache first (cache for 15 minutes)
cached_providers = cache.get(cache_key)
if cached_providers is not None:
return Response(cached_providers)
# Try to get from cache first (cache for 15 minutes)
cached_providers = cache.get(cache_key)
if cached_providers is not None:
return Response(cached_providers)
providers_list = []
providers_list = []
# Optimized query: filter by site and order by provider name
from allauth.socialaccount.models import SocialApp
# Optimized query: filter by site and order by provider name
from allauth.socialaccount.models import SocialApp
social_apps = SocialApp.objects.filter(sites=site).order_by("provider")
for social_app in social_apps:
try:
# Simplified provider name resolution - avoid expensive provider class loading
provider_name = social_app.name or social_app.provider.title()
social_apps = SocialApp.objects.filter(sites=site).order_by("provider")
except ObjectDoesNotExist:
# If no social apps exist, return empty list
social_apps = []
# Build auth URL efficiently
auth_url = request.build_absolute_uri(
f"/accounts/{social_app.provider}/login/"
)
for social_app in social_apps:
try:
# Simplified provider name resolution - avoid expensive provider class loading
provider_name = social_app.name or social_app.provider.title()
providers_list.append(
{
"id": social_app.provider,
"name": provider_name,
"authUrl": auth_url,
}
)
# Build auth URL efficiently
auth_url = request.build_absolute_uri(
f"/accounts/{social_app.provider}/login/"
)
except Exception:
# Skip if provider can't be loaded
continue
providers_list.append(
{
"id": social_app.provider,
"name": provider_name,
"authUrl": auth_url,
}
)
# Serialize and cache the result
serializer = SocialProviderOutputSerializer(providers_list, many=True)
response_data = serializer.data
except Exception:
# Skip if provider can't be loaded
continue
# Cache for 15 minutes (900 seconds)
cache.set(cache_key, response_data, 900)
# Serialize and cache the result
serializer = SocialProviderOutputSerializer(providers_list, many=True)
response_data = serializer.data
return Response(response_data)
# Cache for 15 minutes (900 seconds)
cache.set(cache_key, response_data, 900)
return Response(response_data)
except Exception as e:
# Return a proper JSON error response instead of letting it bubble up
return Response(
{
"status": "error",
"error": {
"code": "SOCIAL_PROVIDERS_ERROR",
"message": "Unable to retrieve social providers",
"details": str(e) if str(e) else None,
},
"data": None,
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@extend_schema_view(

View File

@@ -55,7 +55,9 @@ except ImportError:
@extend_schema_view(
get=extend_schema(
summary="Health check",
description="Get comprehensive health check information including system metrics.",
description=(
"Get comprehensive health check information including system metrics."
),
responses={
200: HealthCheckOutputSerializer,
503: HealthCheckOutputSerializer,
@@ -104,18 +106,30 @@ class HealthCheckAPIView(APIView):
# Process individual health checks
for plugin in plugins:
plugin_name = plugin.identifier()
# Handle both plugin objects and strings
if hasattr(plugin, 'identifier'):
plugin_name = plugin.identifier()
plugin_class_name = plugin.__class__.__name__
critical_service = getattr(plugin, "critical_service", False)
response_time = getattr(plugin, "_response_time", None)
else:
# If plugin is a string, use it directly
plugin_name = str(plugin)
plugin_class_name = plugin_name
critical_service = False
response_time = None
plugin_errors = (
errors.get(plugin.__class__.__name__, [])
errors.get(plugin_class_name, [])
if isinstance(errors, dict)
else []
)
health_data["checks"][plugin_name] = {
"status": "healthy" if not plugin_errors else "unhealthy",
"critical": getattr(plugin, "critical_service", False),
"critical": critical_service,
"errors": [str(error) for error in plugin_errors],
"response_time_ms": getattr(plugin, "_response_time", None),
"response_time_ms": response_time,
}
# Calculate total response time
@@ -320,6 +334,16 @@ class PerformanceMetricsAPIView(APIView):
},
tags=["Health"],
),
options=extend_schema(
summary="CORS preflight for simple health check",
description=(
"Handle CORS preflight requests for the simple health check endpoint."
),
responses={
200: SimpleHealthOutputSerializer,
},
tags=["Health"],
),
)
class SimpleHealthAPIView(APIView):
"""Simple health check endpoint for load balancers."""
@@ -342,7 +366,7 @@ class SimpleHealthAPIView(APIView):
"timestamp": timezone.now(),
}
serializer = SimpleHealthOutputSerializer(response_data)
return Response(serializer.data)
return Response(serializer.data, status=200)
except Exception as e:
response_data = {
"status": "error",
@@ -351,3 +375,12 @@ class SimpleHealthAPIView(APIView):
}
serializer = SimpleHealthOutputSerializer(response_data)
return Response(serializer.data, status=503)
def options(self, request: Request) -> Response:
"""Handle OPTIONS requests for CORS preflight."""
response_data = {
"status": "ok",
"timestamp": timezone.now(),
}
serializer = SimpleHealthOutputSerializer(response_data)
return Response(serializer.data)

View File

@@ -0,0 +1,358 @@
"""
Statistics API views for ThrillWiki.
Provides aggregate statistics about the platform's content including
counts of parks, rides, manufacturers, and other entities.
"""
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAdminUser
from django.db.models import Count, Q
from django.core.cache import cache
from django.utils import timezone
from drf_spectacular.utils import extend_schema, OpenApiExample
from datetime import datetime, timedelta
from apps.parks.models import Park, ParkReview, ParkPhoto, Company as ParkCompany
from apps.rides.models import Ride, RollerCoasterStats, RideReview, RidePhoto, Company as RideCompany
from ..serializers.stats import StatsSerializer
class StatsAPIView(APIView):
"""
API endpoint that returns aggregate statistics about the platform.
Returns counts of various entities like parks, rides, manufacturers, etc.
Results are cached for performance.
"""
permission_classes = [AllowAny]
def _get_relative_time(self, timestamp_str):
"""
Convert an ISO timestamp to a human-readable relative time.
Args:
timestamp_str: ISO format timestamp string
Returns:
str: Human-readable relative time (e.g., "2 days, 3 hours, 15 minutes ago", "just now")
"""
if not timestamp_str or timestamp_str == 'just_now':
return 'just now'
try:
# Parse the ISO timestamp
if isinstance(timestamp_str, str):
timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
else:
timestamp = timestamp_str
# Make timezone-aware if needed
if timestamp.tzinfo is None:
timestamp = timezone.make_aware(timestamp)
now = timezone.now()
diff = now - timestamp
total_seconds = int(diff.total_seconds())
# If less than a minute, return "just now"
if total_seconds < 60:
return 'just now'
# Calculate time components
days = diff.days
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
# Build the relative time string
parts = []
if days > 0:
parts.append(f'{days} day{"s" if days != 1 else ""}')
if hours > 0:
parts.append(f'{hours} hour{"s" if hours != 1 else ""}')
if minutes > 0:
parts.append(f'{minutes} minute{"s" if minutes != 1 else ""}')
# Join parts with commas and add "ago"
if len(parts) == 0:
return 'just now'
elif len(parts) == 1:
return f'{parts[0]} ago'
elif len(parts) == 2:
return f'{parts[0]} and {parts[1]} ago'
else:
return f'{", ".join(parts[:-1])}, and {parts[-1]} ago'
except (ValueError, TypeError):
return 'unknown'
@extend_schema(
operation_id="get_platform_stats",
summary="Get platform statistics",
description="""
Returns comprehensive aggregate statistics about the ThrillWiki platform.
This endpoint provides detailed counts and breakdowns of all major entities including:
- Parks, rides, and roller coasters
- Companies (manufacturers, operators, designers, property owners)
- Photos and reviews
- Ride categories (roller coasters, dark rides, flat rides, etc.)
- Status breakdowns (operating, closed, under construction, etc.)
Results are cached for 5 minutes for optimal performance and automatically
invalidated when relevant data changes.
**No authentication required** - this is a public endpoint.
""".strip(),
responses={
200: StatsSerializer,
500: {
"type": "object",
"properties": {
"error": {"type": "string", "description": "Error message if statistics calculation fails"}
}
}
},
tags=["Statistics"],
examples=[
OpenApiExample(
name="Sample Response",
description="Example of platform statistics response",
value={
"total_parks": 7,
"total_rides": 10,
"total_manufacturers": 6,
"total_operators": 7,
"total_designers": 4,
"total_property_owners": 0,
"total_roller_coasters": 8,
"total_photos": 0,
"total_park_photos": 0,
"total_ride_photos": 0,
"total_reviews": 8,
"total_park_reviews": 4,
"total_ride_reviews": 4,
"roller_coasters": 10,
"operating_parks": 7,
"operating_rides": 10,
"last_updated": "2025-08-28T17:34:59.677143+00:00",
"relative_last_updated": "just now"
}
)
]
)
def get(self, request):
"""Get platform statistics."""
# Try to get cached stats first
cache_key = "platform_stats"
cached_stats = cache.get(cache_key)
if cached_stats:
return Response(cached_stats, status=status.HTTP_200_OK)
# Calculate fresh stats
stats = self._calculate_stats()
# Cache for 5 minutes
cache.set(cache_key, stats, 300)
return Response(stats, status=status.HTTP_200_OK)
def _calculate_stats(self):
"""Calculate all platform statistics."""
# Basic entity counts
total_parks = Park.objects.count()
total_rides = Ride.objects.count()
# Company counts by role
total_manufacturers = RideCompany.objects.filter(
roles__contains=["MANUFACTURER"]
).count()
total_operators = ParkCompany.objects.filter(
roles__contains=["OPERATOR"]
).count()
total_designers = RideCompany.objects.filter(
roles__contains=["DESIGNER"]
).count()
total_property_owners = ParkCompany.objects.filter(
roles__contains=["PROPERTY_OWNER"]
).count()
# Photo counts (combined)
total_park_photos = ParkPhoto.objects.count()
total_ride_photos = RidePhoto.objects.count()
total_photos = total_park_photos + total_ride_photos
# Ride type counts
total_roller_coasters = RollerCoasterStats.objects.count()
# Ride category counts
ride_categories = Ride.objects.values('category').annotate(
count=Count('id')
).exclude(category='')
category_stats = {}
for category in ride_categories:
category_code = category['category']
category_count = category['count']
# Convert category codes to readable names
category_names = {
'RC': 'roller_coasters',
'DR': 'dark_rides',
'FR': 'flat_rides',
'WR': 'water_rides',
'TR': 'transport_rides',
'OT': 'other_rides'
}
category_name = category_names.get(
category_code, f'category_{category_code.lower()}')
category_stats[category_name] = category_count
# Park status counts
park_statuses = Park.objects.values('status').annotate(
count=Count('id')
)
park_status_stats = {}
for status_item in park_statuses:
status_code = status_item['status']
status_count = status_item['count']
# Convert status codes to readable names
status_names = {
'OPERATING': 'operating_parks',
'CLOSED_TEMP': 'temporarily_closed_parks',
'CLOSED_PERM': 'permanently_closed_parks',
'UNDER_CONSTRUCTION': 'under_construction_parks',
'DEMOLISHED': 'demolished_parks',
'RELOCATED': 'relocated_parks'
}
status_name = status_names.get(status_code, f'status_{status_code.lower()}')
park_status_stats[status_name] = status_count
# Ride status counts
ride_statuses = Ride.objects.values('status').annotate(
count=Count('id')
)
ride_status_stats = {}
for status_item in ride_statuses:
status_code = status_item['status']
status_count = status_item['count']
# Convert status codes to readable names
status_names = {
'OPERATING': 'operating_rides',
'CLOSED_TEMP': 'temporarily_closed_rides',
'SBNO': 'sbno_rides',
'CLOSING': 'closing_rides',
'CLOSED_PERM': 'permanently_closed_rides',
'UNDER_CONSTRUCTION': 'under_construction_rides',
'DEMOLISHED': 'demolished_rides',
'RELOCATED': 'relocated_rides'
}
status_name = status_names.get(
status_code, f'ride_status_{status_code.lower()}')
ride_status_stats[status_name] = status_count
# Review counts
total_park_reviews = ParkReview.objects.count()
total_ride_reviews = RideReview.objects.count()
total_reviews = total_park_reviews + total_ride_reviews
# Timestamp handling
now = timezone.now()
last_updated_iso = now.isoformat()
# Get cached timestamp or use current time
cached_timestamp = cache.get('platform_stats_timestamp')
if cached_timestamp and cached_timestamp != 'just_now':
# Use cached timestamp for consistency
last_updated_iso = cached_timestamp
else:
# Set new timestamp in cache
cache.set('platform_stats_timestamp', last_updated_iso, 300)
# Calculate relative time
relative_last_updated = self._get_relative_time(last_updated_iso)
# Combine all stats
stats = {
# Core entity counts
'total_parks': total_parks,
'total_rides': total_rides,
'total_manufacturers': total_manufacturers,
'total_operators': total_operators,
'total_designers': total_designers,
'total_property_owners': total_property_owners,
'total_roller_coasters': total_roller_coasters,
# Photo counts
'total_photos': total_photos,
'total_park_photos': total_park_photos,
'total_ride_photos': total_ride_photos,
# Review counts
'total_reviews': total_reviews,
'total_park_reviews': total_park_reviews,
'total_ride_reviews': total_ride_reviews,
# Category breakdowns
**category_stats,
# Status breakdowns
**park_status_stats,
**ride_status_stats,
# Metadata
'last_updated': last_updated_iso,
'relative_last_updated': relative_last_updated
}
return stats
class StatsRecalculateAPIView(APIView):
"""
Admin-only API endpoint to force recalculation of platform statistics.
This endpoint clears the cache and forces a fresh calculation of all statistics.
Only accessible to admin users.
"""
permission_classes = [IsAdminUser]
@extend_schema(exclude=True)
def post(self, request):
"""Force recalculation of platform statistics."""
# Clear the cache
cache.delete("platform_stats")
cache.delete("platform_stats_timestamp")
# Create a new StatsAPIView instance to reuse the calculation logic
stats_view = StatsAPIView()
fresh_stats = stats_view._calculate_stats()
# Cache the fresh stats
cache.set("platform_stats", fresh_stats, 300)
# Return success response with the fresh stats
return Response({
"message": "Platform statistics have been successfully recalculated",
"stats": fresh_stats,
"recalculated_at": timezone.now().isoformat()
}, status=status.HTTP_200_OK)

View File

@@ -7,9 +7,11 @@ including view tracking and other core functionality.
from .view_tracking import ViewTrackingMiddleware, get_view_stats_for_content
from .analytics import PgHistoryContextMiddleware
from .nextjs import APIResponseMiddleware
__all__ = [
"ViewTrackingMiddleware",
"get_view_stats_for_content",
"PgHistoryContextMiddleware",
"APIResponseMiddleware",
]

View File

@@ -38,5 +38,8 @@ class PgHistoryContextMiddleware:
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
return response
# Set the pghistory context with request information
context_data = request_context(request)
with pghistory.context(**context_data):
response = self.get_response(request)
return response

View File

@@ -0,0 +1,48 @@
# backend/apps/core/middleware.py
from django.utils.deprecation import MiddlewareMixin
class APIResponseMiddleware(MiddlewareMixin):
"""
Middleware to ensure consistent API responses for Next.js
"""
def process_response(self, request, response):
# Only process API requests
if not request.path.startswith("/api/"):
return response
# Ensure CORS headers are set
if not response.has_header("Access-Control-Allow-Origin"):
origin = request.META.get("HTTP_ORIGIN")
# Allow localhost/127.0.0.1 (any port) and IPv6 loopback for development
if origin:
import re
# support http or https, IPv4 and IPv6 loopback, any port
localhost_pattern = r"^https?://(localhost|127\.0\.0\.1|\[::1\]):\d+"
if re.match(localhost_pattern, origin):
response["Access-Control-Allow-Origin"] = origin
# Ensure caches vary by Origin
existing_vary = response.get("Vary")
if existing_vary:
response["Vary"] = f"{existing_vary}, Origin"
else:
response["Vary"] = "Origin"
# Helpful dev CORS headers (adjust for your frontend requests)
response["Access-Control-Allow-Methods"] = (
"GET, POST, PUT, PATCH, DELETE, OPTIONS"
)
response["Access-Control-Allow-Headers"] = (
"Authorization, Content-Type, X-Requested-With"
)
# Uncomment if your dev frontend needs to send cookies/auth credentials
# response['Access-Control-Allow-Credentials'] = 'true'
else:
response["Access-Control-Allow-Origin"] = "null"
return response

View File

@@ -21,7 +21,6 @@ class PerformanceMiddleware(MiddlewareMixin):
request._performance_initial_queries = (
len(connection.queries) if hasattr(connection, "queries") else 0
)
return None
def process_response(self, request, response):
"""Log performance metrics after response is ready"""
@@ -158,7 +157,7 @@ class PerformanceMiddleware(MiddlewareMixin):
extra=performance_data,
)
return None # Don't handle the exception, just log it
# Don't return anything - let the exception propagate normally
def _get_client_ip(self, request):
"""Extract client IP address from request"""
@@ -201,7 +200,6 @@ class QueryCountMiddleware(MiddlewareMixin):
request._query_count_start = (
len(connection.queries) if hasattr(connection, "queries") else 0
)
return None
def process_response(self, request, response):
"""Check query count and warn if excessive"""
@@ -253,8 +251,6 @@ class DatabaseConnectionMiddleware(MiddlewareMixin):
)
# Don't block the request, let Django handle the database error
return None
def process_response(self, request, response):
"""Close database connections properly"""
try:
@@ -275,7 +271,6 @@ class CachePerformanceMiddleware(MiddlewareMixin):
request._cache_hits = 0
request._cache_misses = 0
request._cache_start_time = time.time()
return None
def process_response(self, request, response):
"""Log cache performance metrics"""

View File

@@ -280,8 +280,11 @@ class CacheMonitor:
stats = {}
try:
# Redis cache stats
if hasattr(self.cache_service.default_cache, "_cache"):
# Try to get Redis cache stats
cache_backend = self.cache_service.default_cache.__class__.__name__
if "Redis" in cache_backend:
# Attempt to get Redis client and stats
redis_client = self.cache_service.default_cache._cache.get_client()
info = redis_client.info()
stats["redis"] = {
@@ -297,8 +300,16 @@ class CacheMonitor:
misses = info.get("keyspace_misses", 0)
if hits + misses > 0:
stats["redis"]["hit_rate"] = hits / (hits + misses) * 100
else:
# For local memory cache or other backends
stats["cache_backend"] = cache_backend
stats["message"] = f"Cache statistics not available for {cache_backend}"
except Exception as e:
logger.error(f"Error getting cache stats: {e}")
# Don't log as error since this is expected for non-Redis backends
cache_backend = self.cache_service.default_cache.__class__.__name__
stats["cache_backend"] = cache_backend
stats["message"] = f"Cache statistics not available for {cache_backend}"
return stats

View File

@@ -47,7 +47,8 @@ SECRET_KEY = config("SECRET_KEY")
ALLOWED_HOSTS = config("ALLOWED_HOSTS")
# CSRF trusted origins
CSRF_TRUSTED_ORIGINS = config("CSRF_TRUSTED_ORIGINS", default=[]) # type: ignore[arg-type]
CSRF_TRUSTED_ORIGINS = config("CSRF_TRUSTED_ORIGINS",
default=[]) # type: ignore[arg-type]
# Application definition
DJANGO_APPS = [
@@ -110,7 +111,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"core.middleware.PgHistoryContextMiddleware", # Add history context tracking
"apps.core.middleware.analytics.PgHistoryContextMiddleware", # Add history context tracking
"allauth.account.middleware.AccountMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware",
"django_htmx.middleware.HtmxMiddleware",
@@ -305,7 +306,7 @@ REST_FRAMEWORK = {
"rest_framework.parsers.FormParser",
"rest_framework.parsers.MultiPartParser",
],
"EXCEPTION_HANDLER": "core.api.exceptions.custom_exception_handler",
"EXCEPTION_HANDLER": "apps.core.api.exceptions.custom_exception_handler",
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
@@ -317,13 +318,17 @@ REST_FRAMEWORK = {
}
# CORS Settings for API
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS", default=[]) # type: ignore[arg-type]
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS",
default=[]) # type: ignore[arg-type]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = config("CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool) # type: ignore[arg-type]
CORS_ALLOW_ALL_ORIGINS = config(
"CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool) # type: ignore[arg-type]
API_RATE_LIMIT_PER_MINUTE = config("API_RATE_LIMIT_PER_MINUTE", default=60, cast=int) # type: ignore[arg-type]
API_RATE_LIMIT_PER_HOUR = config("API_RATE_LIMIT_PER_HOUR", default=1000, cast=int) # type: ignore[arg-type]
API_RATE_LIMIT_PER_MINUTE = config(
"API_RATE_LIMIT_PER_MINUTE", default=60, cast=int) # type: ignore[arg-type]
API_RATE_LIMIT_PER_HOUR = config(
"API_RATE_LIMIT_PER_HOUR", default=1000, cast=int) # type: ignore[arg-type]
SPECTACULAR_SETTINGS = {
"TITLE": "ThrillWiki API",
"DESCRIPTION": "Comprehensive theme park and ride information API",

View File

@@ -103,6 +103,7 @@ DEVELOPMENT_MIDDLEWARE = [
"nplusone.ext.django.NPlusOneMiddleware",
"core.middleware.performance_middleware.PerformanceMiddleware",
"core.middleware.performance_middleware.QueryCountMiddleware",
"core.middleware.nextjs.APIResponseMiddleware", # Add this
]
# Add development middleware

View File

@@ -1,97 +1,110 @@
# Active Context
c# Active Context
## Current Focus
- **COMPLETED: Vue Shadcn Component Modernization**: Successfully replaced all transparent components with solid shadcn styling
- **COMPLETED: Home.vue Modernization**: Fully updated Home page with solid backgrounds and proper design tokens
- **COMPLETED: Component Enhancement**: All major components now use professional shadcn styling with solid backgrounds
- **COMPLETED: Enhanced Stats API Endpoint**: Successfully updated `/api/v1/stats/` endpoint with comprehensive platform statistics
- **COMPLETED: Maps API Implementation**: Successfully implemented all map endpoints with full functionality
- **Features Implemented**:
- **Stats API**: Entity counts, photo counts, category breakdowns, status breakdowns, review counts, automatic cache invalidation, caching, public access, OpenAPI documentation
- **Maps API**: Location retrieval, bounds filtering, text search, location details, clustering support, caching, comprehensive serializers, OpenAPI documentation
## Recent Changes
**Phase 1: CSS Foundation Update - COMPLETED:**
- **Updated CSS Variables**: Integrated user-provided CSS styling with proper @layer base structure
- **New Color Scheme**: Primary purple theme (262.1 83.3% 57.8%) with solid backgrounds
- **Design Token Integration**: Proper CSS variables for background, foreground, card, primary, secondary, muted, accent, destructive, border, input, and ring colors
- **Dark Mode Support**: Complete dark mode color palette with solid backgrounds (no transparency)
**Enhanced Stats API Endpoint - COMPLETED:**
- **Updated**: `/api/v1/stats/` endpoint for platform statistics
- **Files Created/Modified**:
- `backend/apps/api/v1/views/stats.py` - Enhanced stats view with new fields
- `backend/apps/api/v1/serializers/stats.py` - Updated serializer with new fields
- `backend/apps/api/v1/signals.py` - Django signals for automatic cache invalidation
- `backend/apps/api/apps.py` - App config to load signals
- `backend/apps/api/v1/urls.py` - Stats URL routing
**Phase 2: Component Modernization - IN PROGRESS:**
- **RideCard.vue Enhancement**:
- Replaced custom div with shadcn Card, CardContent, CardHeader, CardTitle, CardDescription
- Updated to use Badge components with proper variants (default, destructive, secondary, outline)
- Integrated lucide-vue-next icons (Camera, MapPin, TrendingUp, Zap, Clock, Users, Star, Building, User)
- **Solid Backgrounds**: Removed all transparency issues (bg-purple-900/30 → bg-purple-800, etc.)
- **Enhanced Visual Design**: border-2, bg-card, proper hover states with solid colors
- **Professional Status Badges**: Dynamic variants based on ride status with shadow-md
**Maps API Implementation - COMPLETED:**
- **Implemented**: Complete maps API with 4 main endpoints
- **Files Created/Modified**:
- `backend/apps/api/v1/maps/views.py` - All map view implementations
- `backend/apps/api/v1/serializers/maps.py` - Comprehensive map serializers
- `backend/apps/api/v1/maps/urls.py` - Map URL routing (existing)
- **PresetItem.vue Enhancement**:
- Converted to use shadcn Card, CardContent, CardTitle, CardDescription
- Integrated Badge components for Default/Global indicators with solid backgrounds
- Added Button components with proper ghost variants for actions
- **DropdownMenu Integration**: Professional context menu with proper hover states
- **Solid Color Scheme**: bg-green-100 dark:bg-green-800 (no transparency)
- **Enhanced Interactions**: Proper hover:bg-accent, cursor-pointer states
**Technical Infrastructure:**
- **Import Resolution**: Fixed all component import paths for shadcn components
- **Type Safety**: Proper TypeScript integration with FilterPreset from @/types/filters
- **Icon System**: Migrated from custom Icon component to lucide-vue-next consistently
- **Design System**: All components now use design tokens (text-muted-foreground, bg-card, border-border, etc.)
**Previous Major Enhancements:**
- Successfully initialized shadcn-vue with comprehensive component library
- Enhanced ParkList.vue and RideList.vue with advanced shadcn components
- Fixed JavaScript errors and improved type safety across components
- Django Sites framework and API authentication working correctly
**Technical Implementation:**
- **Stats Endpoint**: GET `/api/v1/stats/` - Returns comprehensive platform statistics
- **Maps Endpoints**:
- GET `/api/v1/maps/locations/` - Get map locations with filtering, bounds, search, clustering
- GET `/api/v1/maps/locations/<type>/<id>/` - Get detailed location information
- GET `/api/v1/maps/search/` - Search locations by text query with pagination
- GET `/api/v1/maps/bounds/` - Get locations within geographic bounds
- GET `/api/v1/maps/stats/` - Get map service statistics
- DELETE/POST `/api/v1/maps/cache/` - Cache management endpoints
- **Authentication**: Public endpoints (AllowAny permission)
- **Caching**: 5-minute cache with automatic invalidation for maps, immediate cache for stats
- **Documentation**: Full OpenAPI schema with drf-spectacular for all endpoints
- **Response Format**: JSON with comprehensive location data, statistics, and metadata
- **Features**: Geographic bounds filtering, text search, pagination, clustering support, detailed location info
## Active Files
### Moderation System
- moderation/models.py
- moderation/urls.py
- moderation/views.py
- templates/moderation/dashboard.html
- templates/moderation/partials/
- submission_list.html
- moderation_nav.html
- dashboard_content.html
### Stats API Files
- `backend/apps/api/v1/views/stats.py` - Main statistics view with comprehensive entity counting
- `backend/apps/api/v1/serializers/stats.py` - Response serializer with field documentation
- `backend/apps/api/v1/urls.py` - URL routing including new stats endpoint
### Maps API Files
- `backend/apps/api/v1/maps/views.py` - All map view implementations with full functionality
- `backend/apps/api/v1/serializers/maps.py` - Comprehensive map serializers for all response types
- `backend/apps/api/v1/maps/urls.py` - Map URL routing configuration
## Next Steps
1. Review and enhance moderation dashboard functionality
2. Implement remaining submission review workflows
3. Test moderation system end-to-end
4. Document moderation patterns and guidelines
1. **Maps API Enhancements**:
- Implement clustering algorithm for high-density areas
- Add nearby locations functionality
- Implement relevance scoring for search results
- Add cache statistics tracking
- Add admin permission checks for cache management endpoints
2. **Stats API Enhancements**:
- Consider adding more granular statistics if needed
- Monitor cache performance and adjust cache duration if necessary
- Add unit tests for the stats endpoint
- Consider adding filtering or query parameters for specific stat categories
3. **Testing**: Add comprehensive unit tests for all map endpoints
4. **Performance**: Monitor and optimize database queries for large datasets
## Current Development State
- Using Django for backend framework
- HTMX for dynamic interactions
- AlpineJS for client-side functionality
- Tailwind CSS for styling
- Python manage.py tailwind runserver for development
- Django backend with comprehensive stats API
- Stats endpoint fully functional at `/api/v1/stats/`
- Server running on port 8000
- All middleware issues resolved
## Testing Requirements
- Verify all moderation workflows
- Test submission review process
- Validate user role permissions
- Check notification systems
## Testing Results
- **Stats Endpoint**: `/api/v1/stats/` - ✅ Working correctly
- **Maps Endpoints**: All implemented and ready for testing
- `/api/v1/maps/locations/` - ✅ Implemented with filtering, bounds, search
- `/api/v1/maps/locations/<type>/<id>/` - ✅ Implemented with detailed location info
- `/api/v1/maps/search/` - ✅ Implemented with text search and pagination
- `/api/v1/maps/bounds/` - ✅ Implemented with geographic bounds filtering
- `/api/v1/maps/stats/` - ✅ Implemented with location statistics
- `/api/v1/maps/cache/` - ✅ Implemented with cache management
- **Response**: Returns comprehensive JSON with location data and statistics
- **Performance**: Cached responses for optimal performance (5-minute cache)
- **Access**: Public endpoints, no authentication required
- **Documentation**: Full OpenAPI documentation available
## Deployment Notes
- Site runs at http://thrillwiki.com
- Changes must be committed to git and pushed to main
- HTMX templates located in partials folders by model
## Active Issues/Considerations
- Django Sites framework properly configured for development
- Auth providers endpoint working correctly
- Rides API endpoint now working correctly (501 error resolved)
## Recent Decisions
- Fixed Sites framework by creating Site objects for development domains
- Confirmed auth system is working properly
- Sites framework now supports localhost, testserver, and port-specific domains
## Issue Resolution Summary
**Problem**: Django Sites framework error - "Site matching query does not exist"
**Root Cause**: Missing Site objects in database for development domains
**Solution**: Created Site objects for:
- 127.0.0.1 (ID: 2) - ThrillWiki Local (no port)
- 127.0.0.1:8000 (ID: 1) - ThrillWiki Local
- testserver (ID: 3) - ThrillWiki Test Server
**Result**: Auth providers endpoint now returns 200 status with empty array (expected behavior)
## Sample Response
```json
{
"total_parks": 7,
"total_rides": 10,
"total_manufacturers": 6,
"total_operators": 7,
"total_designers": 4,
"total_property_owners": 0,
"total_roller_coasters": 8,
"total_photos": 0,
"total_park_photos": 0,
"total_ride_photos": 0,
"total_reviews": 8,
"total_park_reviews": 4,
"total_ride_reviews": 4,
"roller_coasters": 10,
"operating_parks": 7,
"operating_rides": 10,
"last_updated": "just_now"
}
```

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

View File

@@ -1,6 +0,0 @@
# Development environment configuration
VITE_API_BASE_URL=
VITE_APP_ENV=development
VITE_APP_NAME=ThrillWiki
VITE_APP_VERSION=1.0.0
VITE_DEBUG=true

View File

@@ -1,6 +0,0 @@
# Production environment configuration
VITE_API_BASE_URL=https://api.thrillwiki.com
VITE_APP_ENV=production
VITE_APP_NAME=ThrillWiki
VITE_APP_VERSION=1.0.0
VITE_DEBUG=false

View File

@@ -1,6 +0,0 @@
# Staging environment configuration
VITE_API_BASE_URL=https://staging-api.thrillwiki.com
VITE_APP_ENV=staging
VITE_APP_NAME=ThrillWiki (Staging)
VITE_APP_VERSION=1.0.0
VITE_DEBUG=true

View File

@@ -1,3 +0,0 @@
* text=auto eol=lf
# SCM syntax highlighting & preventing 3-way merges
pixi.lock merge=binary linguist-language=YAML linguist-generated=true

36
frontend/.gitignore vendored
View File

@@ -1,36 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
test-results/
playwright-report/
# pixi environments
.pixi/*
!.pixi/config.toml

View File

@@ -1 +0,0 @@
lts/*

View File

@@ -1,6 +0,0 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

View File

@@ -1,384 +0,0 @@
# ThrillWiki Frontend
Modern Vue.js 3 SPA frontend for the ThrillWiki theme park and roller coaster information system.
## 🏗️ Architecture Overview
This frontend is built with Vue 3 and follows modern development practices:
```
frontend/
├── src/
│ ├── components/ # Reusable UI components
│ │ ├── ui/ # Base UI components (shadcn-vue style)
│ │ ├── layout/ # Layout components (Navbar, ThemeController)
│ │ ├── button/ # Button variants
│ │ ├── icon/ # Icon components
│ │ └── state-layer/ # Material Design state layers
│ ├── views/ # Page components
│ │ ├── Home.vue # Landing page
│ │ ├── SearchResults.vue # Search results page
│ │ ├── parks/ # Park-related pages
│ │ └── rides/ # Ride-related pages
│ ├── stores/ # Pinia state management
│ ├── router/ # Vue Router configuration
│ ├── services/ # API services and utilities
│ ├── types/ # TypeScript type definitions
│ ├── App.vue # Root component
│ └── main.ts # Application entry point
├── public/ # Static assets
├── dist/ # Production build output
└── e2e/ # End-to-end tests
```
## 🚀 Technology Stack
### Core Framework
- **Vue 3** with Composition API and `<script setup>` syntax
- **TypeScript** for type safety and better developer experience
- **Vite** for lightning-fast development and optimized production builds
### UI & Styling
- **Tailwind CSS v4** with custom design system
- **shadcn-vue** inspired component library
- **Material Design** state layers and interactions
- **Dark mode support** with automatic theme detection
### State Management & Routing
- **Pinia** for predictable state management
- **Vue Router 4** for client-side routing
### Development & Testing
- **Vitest** for fast unit testing
- **Playwright** for end-to-end testing
- **ESLint** with Vue and TypeScript rules
- **Prettier** for code formatting
- **Vue DevTools** integration
### Build & Performance
- **Vite** with optimized build pipeline
- **Vue 3's reactivity system** for optimal performance
- **Tree-shaking** and code splitting
- **PWA capabilities** for mobile experience
## 🛠️ Development Workflow
### Prerequisites
- **Node.js 20+** (see `engines` in package.json)
- **pnpm** package manager
- **Backend API** running on `http://localhost:8000`
### Setup
1. **Install dependencies**
```bash
cd frontend
pnpm install
```
2. **Environment configuration**
```bash
cp .env.development .env.local
# Edit .env.local with your settings
```
3. **Start development server**
```bash
pnpm dev
```
The application will be available at `http://localhost:5174`
### Available Scripts
```bash
# Development
pnpm dev # Start dev server with hot reload
pnpm preview # Preview production build locally
# Building
pnpm build # Build for production
pnpm build-only # Build without type checking
pnpm type-check # TypeScript type checking only
# Testing
pnpm test:unit # Run unit tests with Vitest
pnpm test:e2e # Run E2E tests with Playwright
# Code Quality
pnpm lint # Run ESLint with auto-fix
pnpm lint:eslint # ESLint only
pnpm lint:oxlint # Oxlint (fast linter) only
pnpm format # Format code with Prettier
# Component Development
pnpm add # Add new components with Liftkit
```
## 🔧 Configuration
### Environment Variables
Create `.env.local` for local development:
```bash
# API Configuration
VITE_API_BASE_URL=http://localhost:8000/api
# Application Settings
VITE_APP_TITLE=ThrillWiki (Development)
VITE_APP_VERSION=1.0.0
# Feature Flags
VITE_ENABLE_DEBUG=true
VITE_ENABLE_ANALYTICS=false
# Theme
VITE_DEFAULT_THEME=system
```
### Vite Configuration
The build system is configured in `vite.config.ts` with:
- **Vue 3** plugin with JSX support
- **Path aliases** for clean imports
- **CSS preprocessing** with PostCSS and Tailwind
- **Development server** with proxy to backend API
- **Build optimizations** for production
### Tailwind CSS
Custom design system configured in `tailwind.config.js`:
- **Custom color palette** with CSS variables
- **Dark mode support** with `class` strategy
- **Component classes** for consistent styling
- **Material Design** inspired design tokens
## 📁 Project Structure Details
### Components Architecture
#### UI Components (`src/components/ui/`)
Base component library following shadcn-vue patterns:
- **Button** - Multiple variants and sizes
- **Card** - Flexible content containers
- **Badge** - Status indicators and labels
- **SearchInput** - Search functionality with debouncing
- **Input, Textarea, Select** - Form components
- **Dialog, Sheet, Dropdown** - Overlay components
#### Layout Components (`src/components/layout/`)
Application layout and navigation:
- **Navbar** - Main navigation with responsive design
- **ThemeController** - Dark/light mode toggle
- **Footer** - Site footer with links
#### Specialized Components
- **State Layer** - Material Design ripple effects
- **Icon** - Lucide React icon wrapper
- **Button variants** - Different button styles
### Views Structure
#### Page Components (`src/views/`)
- **Home.vue** - Landing page with featured content
- **SearchResults.vue** - Global search results display
- **parks/ParkList.vue** - List of all parks
- **parks/ParkDetail.vue** - Individual park information
- **rides/RideList.vue** - List of rides with filtering
- **rides/RideDetail.vue** - Detailed ride information
### State Management
#### Pinia Stores (`src/stores/`)
- **Theme Store** - Dark/light mode state
- **Search Store** - Search functionality and results
- **Park Store** - Park data management
- **Ride Store** - Ride data management
- **UI Store** - General UI state
### API Integration
#### Services (`src/services/`)
- **API client** with Axios configuration
- **Authentication** service
- **Park service** - CRUD operations for parks
- **Ride service** - CRUD operations for rides
- **Search service** - Global search functionality
### Type Definitions
#### TypeScript Types (`src/types/`)
- **API response types** matching backend serializers
- **Component prop types** for better type safety
- **Store state types** for Pinia stores
- **Utility types** for common patterns
## 🎨 Design System
### Color Palette
- **Primary colors** - Brand identity
- **Semantic colors** - Success, warning, error states
- **Neutral colors** - Grays for text and backgrounds
- **Dark mode variants** - Automatic color adjustments
### Typography
- **Inter font family** for modern appearance
- **Responsive text scales** for all screen sizes
- **Consistent line heights** for readability
### Component Variants
- **Button variants** - Primary, secondary, outline, ghost
- **Card variants** - Default, elevated, outlined
- **Input variants** - Default, error, success
### Dark Mode
- **Automatic detection** of system preference
- **Manual toggle** in theme controller
- **Smooth transitions** between themes
- **CSS custom properties** for dynamic theming
## 🧪 Testing Strategy
### Unit Tests (Vitest)
- **Component testing** with Vue Test Utils
- **Composable testing** for custom hooks
- **Service testing** for API calls
- **Store testing** for Pinia state management
### End-to-End Tests (Playwright)
- **User journey testing** - Complete user flows
- **Cross-browser testing** - Chrome, Firefox, Safari
- **Mobile testing** - Responsive behavior
- **Accessibility testing** - WCAG compliance
### Test Configuration
- **Vitest config** in `vitest.config.ts`
- **Playwright config** in `playwright.config.ts`
- **Test utilities** in `src/__tests__/`
- **Mock data** for consistent testing
## 🚀 Deployment
### Build Process
```bash
# Production build
pnpm build
# Preview build locally
pnpm preview
# Type checking before build
pnpm type-check
```
### Build Output
- **Optimized bundles** with code splitting
- **Asset optimization** (images, fonts, CSS)
- **Source maps** for debugging (development only)
- **Service worker** for PWA features
### Environment Configurations
- **Development** - `.env.development`
- **Staging** - `.env.staging`
- **Production** - `.env.production`
## 🔧 Development Tools
### IDE Setup
- **VSCode** with Volar extension
- **Vue Language Features** for better Vue support
- **TypeScript Importer** for auto-imports
- **Tailwind CSS IntelliSense** for styling
### Browser Extensions
- **Vue DevTools** for debugging
- **Tailwind CSS DevTools** for styling
- **Playwright Inspector** for E2E testing
### Performance Monitoring
- **Vite's built-in analyzer** for bundle analysis
- **Vue DevTools performance tab**
- **Lighthouse** for performance metrics
## 📖 API Integration
### Backend Communication
- **RESTful API** integration with Django backend
- **Automatic field conversion** (snake_case ↔ camelCase)
- **Error handling** with user-friendly messages
- **Loading states** for better UX
### Authentication Flow
- **JWT token management**
- **Automatic token refresh**
- **Protected routes** with guards
- **User session management**
## 🤝 Contributing
### Code Standards
1. **Vue 3 Composition API** with `<script setup>` syntax
2. **TypeScript** for all new components and utilities
3. **Component naming** following Vue.js conventions
4. **CSS classes** using Tailwind utility classes
### Development Process
1. **Create feature branch** from `main`
2. **Follow component structure** guidelines
3. **Add tests** for new functionality
4. **Update documentation** as needed
5. **Submit pull request** with description
### Component Creation
```bash
# Add new component with Liftkit
pnpm add
# Follow the prompts to create component structure
```
## 🐛 Troubleshooting
### Common Issues
#### Build Errors
- **TypeScript errors** - Run `pnpm type-check` to identify issues
- **Missing dependencies** - Run `pnpm install` to sync packages
- **Vite configuration** - Check `vite.config.ts` for build settings
#### Runtime Errors
- **API connection** - Verify backend is running on port 8000
- **Environment variables** - Check `.env.local` configuration
- **CORS issues** - Configure backend CORS settings
#### Development Issues
- **Hot reload not working** - Restart dev server
- **Type errors** - Check TypeScript configuration
- **Styling issues** - Verify Tailwind classes
### Performance Tips
- **Use Composition API** for better performance
- **Lazy load components** for better initial load
- **Optimize images** and assets
- **Use `computed` properties** for derived state
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](../LICENSE) file for details.
## 🙏 Acknowledgments
- **Vue.js Team** for the excellent framework
- **Vite Team** for the blazing fast build tool
- **Tailwind CSS** for the utility-first approach
- **shadcn-vue** for component inspiration
- **ThrillWiki Community** for feedback and support
---
**Built with ❤️ for the theme park and roller coaster community**

File diff suppressed because it is too large Load Diff

View File

@@ -1,216 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ActiveFilterChip: typeof import('./src/components/filters/ActiveFilterChip.vue')['default']
AlertDialog: typeof import('./src/components/ui/alert-dialog/AlertDialog.vue')['default']
AlertDialogAction: typeof import('./src/components/ui/alert-dialog/AlertDialogAction.vue')['default']
AlertDialogCancel: typeof import('./src/components/ui/alert-dialog/AlertDialogCancel.vue')['default']
AlertDialogContent: typeof import('./src/components/ui/alert-dialog/AlertDialogContent.vue')['default']
AlertDialogDescription: typeof import('./src/components/ui/alert-dialog/AlertDialogDescription.vue')['default']
AlertDialogFooter: typeof import('./src/components/ui/alert-dialog/AlertDialogFooter.vue')['default']
AlertDialogHeader: typeof import('./src/components/ui/alert-dialog/AlertDialogHeader.vue')['default']
AlertDialogTitle: typeof import('./src/components/ui/alert-dialog/AlertDialogTitle.vue')['default']
AlertDialogTrigger: typeof import('./src/components/ui/alert-dialog/AlertDialogTrigger.vue')['default']
AppSidebar: typeof import('./src/components/AppSidebar.vue')['default']
AuthManager: typeof import('./src/components/auth/AuthManager.vue')['default']
AuthModal: typeof import('./src/components/auth/AuthModal.vue')['default']
AuthPrompt: typeof import('./src/components/entity/AuthPrompt.vue')['default']
Avatar: typeof import('./src/components/ui/avatar/Avatar.vue')['default']
AvatarFallback: typeof import('./src/components/ui/avatar/AvatarFallback.vue')['default']
AvatarImage: typeof import('./src/components/ui/avatar/AvatarImage.vue')['default']
Badge: typeof import('./src/components/ui/Badge.vue')['default']
Breadcrumb: typeof import('./src/components/ui/breadcrumb/Breadcrumb.vue')['default']
BreadcrumbItem: typeof import('./src/components/ui/breadcrumb/BreadcrumbItem.vue')['default']
BreadcrumbLink: typeof import('./src/components/ui/breadcrumb/BreadcrumbLink.vue')['default']
BreadcrumbList: typeof import('./src/components/ui/breadcrumb/BreadcrumbList.vue')['default']
BreadcrumbPage: typeof import('./src/components/ui/breadcrumb/BreadcrumbPage.vue')['default']
BreadcrumbSeparator: typeof import('./src/components/ui/breadcrumb/BreadcrumbSeparator.vue')['default']
Button: typeof import('./src/components/ui/Button.vue')['default']
Card: typeof import('./src/components/ui/Card.vue')['default']
CardAction: typeof import('./src/components/ui/card/CardAction.vue')['default']
CardContent: typeof import('./src/components/ui/card/CardContent.vue')['default']
CardDescription: typeof import('./src/components/ui/card/CardDescription.vue')['default']
CardFooter: typeof import('./src/components/ui/card/CardFooter.vue')['default']
CardHeader: typeof import('./src/components/ui/card/CardHeader.vue')['default']
CardTitle: typeof import('./src/components/ui/card/CardTitle.vue')['default']
Collapsible: typeof import('./src/components/ui/collapsible/Collapsible.vue')['default']
CollapsibleContent: typeof import('./src/components/ui/collapsible/CollapsibleContent.vue')['default']
CollapsibleTrigger: typeof import('./src/components/ui/collapsible/CollapsibleTrigger.vue')['default']
Command: typeof import('./src/components/ui/command/Command.vue')['default']
CommandDialog: typeof import('./src/components/ui/command/CommandDialog.vue')['default']
CommandEmpty: typeof import('./src/components/ui/command/CommandEmpty.vue')['default']
CommandGroup: typeof import('./src/components/ui/command/CommandGroup.vue')['default']
CommandInput: typeof import('./src/components/ui/command/CommandInput.vue')['default']
CommandItem: typeof import('./src/components/ui/command/CommandItem.vue')['default']
CommandList: typeof import('./src/components/ui/command/CommandList.vue')['default']
CommandSeparator: typeof import('./src/components/ui/command/CommandSeparator.vue')['default']
CommandShortcut: typeof import('./src/components/ui/command/CommandShortcut.vue')['default']
ContextMenu: typeof import('./src/components/ui/context-menu/ContextMenu.vue')['default']
ContextMenuCheckboxItem: typeof import('./src/components/ui/context-menu/ContextMenuCheckboxItem.vue')['default']
ContextMenuContent: typeof import('./src/components/ui/context-menu/ContextMenuContent.vue')['default']
ContextMenuGroup: typeof import('./src/components/ui/context-menu/ContextMenuGroup.vue')['default']
ContextMenuItem: typeof import('./src/components/ui/context-menu/ContextMenuItem.vue')['default']
ContextMenuLabel: typeof import('./src/components/ui/context-menu/ContextMenuLabel.vue')['default']
ContextMenuPortal: typeof import('./src/components/ui/context-menu/ContextMenuPortal.vue')['default']
ContextMenuRadioGroup: typeof import('./src/components/ui/context-menu/ContextMenuRadioGroup.vue')['default']
ContextMenuRadioItem: typeof import('./src/components/ui/context-menu/ContextMenuRadioItem.vue')['default']
ContextMenuSeparator: typeof import('./src/components/ui/context-menu/ContextMenuSeparator.vue')['default']
ContextMenuShortcut: typeof import('./src/components/ui/context-menu/ContextMenuShortcut.vue')['default']
ContextMenuSub: typeof import('./src/components/ui/context-menu/ContextMenuSub.vue')['default']
ContextMenuSubContent: typeof import('./src/components/ui/context-menu/ContextMenuSubContent.vue')['default']
ContextMenuSubTrigger: typeof import('./src/components/ui/context-menu/ContextMenuSubTrigger.vue')['default']
ContextMenuTrigger: typeof import('./src/components/ui/context-menu/ContextMenuTrigger.vue')['default']
DateRangeFilter: typeof import('./src/components/filters/DateRangeFilter.vue')['default']
Dialog: typeof import('./src/components/ui/dialog/Dialog.vue')['default']
DialogClose: typeof import('./src/components/ui/dialog/DialogClose.vue')['default']
DialogContent: typeof import('./src/components/ui/dialog/DialogContent.vue')['default']
DialogDescription: typeof import('./src/components/ui/dialog/DialogDescription.vue')['default']
DialogFooter: typeof import('./src/components/ui/dialog/DialogFooter.vue')['default']
DialogHeader: typeof import('./src/components/ui/dialog/DialogHeader.vue')['default']
DialogOverlay: typeof import('./src/components/ui/dialog/DialogOverlay.vue')['default']
DialogScrollContent: typeof import('./src/components/ui/dialog/DialogScrollContent.vue')['default']
DialogTitle: typeof import('./src/components/ui/dialog/DialogTitle.vue')['default']
DialogTrigger: typeof import('./src/components/ui/dialog/DialogTrigger.vue')['default']
DiscordIcon: typeof import('./src/components/icons/DiscordIcon.vue')['default']
Divider: typeof import('primevue/divider')['default']
Dropdown: typeof import('primevue/dropdown')['default']
DropdownMenu: typeof import('./src/components/ui/dropdown-menu/DropdownMenu.vue')['default']
DropdownMenuCheckboxItem: typeof import('./src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue')['default']
DropdownMenuContent: typeof import('./src/components/ui/dropdown-menu/DropdownMenuContent.vue')['default']
DropdownMenuGroup: typeof import('./src/components/ui/dropdown-menu/DropdownMenuGroup.vue')['default']
DropdownMenuItem: typeof import('./src/components/ui/dropdown-menu/DropdownMenuItem.vue')['default']
DropdownMenuLabel: typeof import('./src/components/ui/dropdown-menu/DropdownMenuLabel.vue')['default']
DropdownMenuRadioGroup: typeof import('./src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue')['default']
DropdownMenuRadioItem: typeof import('./src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue')['default']
DropdownMenuSeparator: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSeparator.vue')['default']
DropdownMenuShortcut: typeof import('./src/components/ui/dropdown-menu/DropdownMenuShortcut.vue')['default']
DropdownMenuSub: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSub.vue')['default']
DropdownMenuSubContent: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSubContent.vue')['default']
DropdownMenuSubTrigger: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue')['default']
DropdownMenuTrigger: typeof import('./src/components/ui/dropdown-menu/DropdownMenuTrigger.vue')['default']
EntitySuggestionCard: typeof import('./src/components/entity/EntitySuggestionCard.vue')['default']
EntitySuggestionManager: typeof import('./src/components/entity/EntitySuggestionManager.vue')['default']
EntitySuggestionModal: typeof import('./src/components/entity/EntitySuggestionModal.vue')['default']
FilterSection: typeof import('./src/components/filters/FilterSection.vue')['default']
ForgotPasswordModal: typeof import('./src/components/auth/ForgotPasswordModal.vue')['default']
GoogleIcon: typeof import('./src/components/icons/GoogleIcon.vue')['default']
HoverCard: typeof import('./src/components/ui/hover-card/HoverCard.vue')['default']
HoverCardContent: typeof import('./src/components/ui/hover-card/HoverCardContent.vue')['default']
HoverCardTrigger: typeof import('./src/components/ui/hover-card/HoverCardTrigger.vue')['default']
Icon: typeof import('./src/components/ui/Icon.vue')['default']
Input: typeof import('./src/components/ui/Input.vue')['default']
InputText: typeof import('primevue/inputtext')['default']
LoginModal: typeof import('./src/components/auth/LoginModal.vue')['default']
Menu: typeof import('primevue/menu')['default']
Menubar: typeof import('./src/components/ui/menubar/Menubar.vue')['default']
MenubarCheckboxItem: typeof import('./src/components/ui/menubar/MenubarCheckboxItem.vue')['default']
MenubarContent: typeof import('./src/components/ui/menubar/MenubarContent.vue')['default']
MenubarGroup: typeof import('./src/components/ui/menubar/MenubarGroup.vue')['default']
MenubarItem: typeof import('./src/components/ui/menubar/MenubarItem.vue')['default']
MenubarLabel: typeof import('./src/components/ui/menubar/MenubarLabel.vue')['default']
MenubarMenu: typeof import('./src/components/ui/menubar/MenubarMenu.vue')['default']
MenubarRadioGroup: typeof import('./src/components/ui/menubar/MenubarRadioGroup.vue')['default']
MenubarRadioItem: typeof import('./src/components/ui/menubar/MenubarRadioItem.vue')['default']
MenubarSeparator: typeof import('./src/components/ui/menubar/MenubarSeparator.vue')['default']
MenubarShortcut: typeof import('./src/components/ui/menubar/MenubarShortcut.vue')['default']
MenubarSub: typeof import('./src/components/ui/menubar/MenubarSub.vue')['default']
MenubarSubContent: typeof import('./src/components/ui/menubar/MenubarSubContent.vue')['default']
MenubarSubTrigger: typeof import('./src/components/ui/menubar/MenubarSubTrigger.vue')['default']
MenubarTrigger: typeof import('./src/components/ui/menubar/MenubarTrigger.vue')['default']
Navbar: typeof import('./src/components/layout/Navbar.vue')['default']
Popover: typeof import('./src/components/ui/popover/Popover.vue')['default']
PopoverAnchor: typeof import('./src/components/ui/popover/PopoverAnchor.vue')['default']
PopoverContent: typeof import('./src/components/ui/popover/PopoverContent.vue')['default']
PopoverTrigger: typeof import('./src/components/ui/popover/PopoverTrigger.vue')['default']
PresetItem: typeof import('./src/components/filters/PresetItem.vue')['default']
PrimeBadge: typeof import('./src/components/primevue/PrimeBadge.vue')['default']
PrimeButton: typeof import('./src/components/primevue/PrimeButton.vue')['default']
PrimeCard: typeof import('./src/components/primevue/PrimeCard.vue')['default']
PrimeDialog: typeof import('./src/components/primevue/PrimeDialog.vue')['default']
PrimeInput: typeof import('./src/components/primevue/PrimeInput.vue')['default']
PrimeProgress: typeof import('./src/components/primevue/PrimeProgress.vue')['default']
PrimeSelect: typeof import('./src/components/primevue/PrimeSelect.vue')['default']
PrimeSkeleton: typeof import('./src/components/primevue/PrimeSkeleton.vue')['default']
PrimeThemeController: typeof import('./src/components/layout/PrimeThemeController.vue')['default']
PrimeVueTest: typeof import('./src/components/test/PrimeVueTest.vue')['default']
Progress: typeof import('./src/components/ui/progress/Progress.vue')['default']
ProgressSpinner: typeof import('primevue/progressspinner')['default']
RangeFilter: typeof import('./src/components/filters/RangeFilter.vue')['default']
RideCard: typeof import('./src/components/rides/RideCard.vue')['default']
RideFilterSidebar: typeof import('./src/components/filters/RideFilterSidebar.vue')['default']
RideListDisplay: typeof import('./src/components/rides/RideListDisplay.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SavePresetDialog: typeof import('./src/components/filters/SavePresetDialog.vue')['default']
ScrollArea: typeof import('./src/components/ui/scroll-area/ScrollArea.vue')['default']
ScrollBar: typeof import('./src/components/ui/scroll-area/ScrollBar.vue')['default']
SearchableSelect: typeof import('./src/components/filters/SearchableSelect.vue')['default']
SearchFilter: typeof import('./src/components/filters/SearchFilter.vue')['default']
SearchInput: typeof import('./src/components/ui/SearchInput.vue')['default']
Select: typeof import('./src/components/ui/select/Select.vue')['default']
SelectContent: typeof import('./src/components/ui/select/SelectContent.vue')['default']
SelectFilter: typeof import('./src/components/filters/SelectFilter.vue')['default']
SelectGroup: typeof import('./src/components/ui/select/SelectGroup.vue')['default']
SelectItem: typeof import('./src/components/ui/select/SelectItem.vue')['default']
SelectItemText: typeof import('./src/components/ui/select/SelectItemText.vue')['default']
SelectLabel: typeof import('./src/components/ui/select/SelectLabel.vue')['default']
SelectScrollDownButton: typeof import('./src/components/ui/select/SelectScrollDownButton.vue')['default']
SelectScrollUpButton: typeof import('./src/components/ui/select/SelectScrollUpButton.vue')['default']
SelectSeparator: typeof import('./src/components/ui/select/SelectSeparator.vue')['default']
SelectTrigger: typeof import('./src/components/ui/select/SelectTrigger.vue')['default']
SelectValue: typeof import('./src/components/ui/select/SelectValue.vue')['default']
Separator: typeof import('./src/components/ui/separator/Separator.vue')['default']
Sheet: typeof import('./src/components/ui/sheet/Sheet.vue')['default']
SheetClose: typeof import('./src/components/ui/sheet/SheetClose.vue')['default']
SheetContent: typeof import('./src/components/ui/sheet/SheetContent.vue')['default']
SheetDescription: typeof import('./src/components/ui/sheet/SheetDescription.vue')['default']
SheetFooter: typeof import('./src/components/ui/sheet/SheetFooter.vue')['default']
SheetHeader: typeof import('./src/components/ui/sheet/SheetHeader.vue')['default']
SheetOverlay: typeof import('./src/components/ui/sheet/SheetOverlay.vue')['default']
SheetTitle: typeof import('./src/components/ui/sheet/SheetTitle.vue')['default']
SheetTrigger: typeof import('./src/components/ui/sheet/SheetTrigger.vue')['default']
Sidebar: typeof import('./src/components/ui/sidebar/Sidebar.vue')['default']
SidebarContent: typeof import('./src/components/ui/sidebar/SidebarContent.vue')['default']
SidebarFooter: typeof import('./src/components/ui/sidebar/SidebarFooter.vue')['default']
SidebarGroup: typeof import('./src/components/ui/sidebar/SidebarGroup.vue')['default']
SidebarGroupAction: typeof import('./src/components/ui/sidebar/SidebarGroupAction.vue')['default']
SidebarGroupContent: typeof import('./src/components/ui/sidebar/SidebarGroupContent.vue')['default']
SidebarGroupLabel: typeof import('./src/components/ui/sidebar/SidebarGroupLabel.vue')['default']
SidebarHeader: typeof import('./src/components/ui/sidebar/SidebarHeader.vue')['default']
SidebarInput: typeof import('./src/components/ui/sidebar/SidebarInput.vue')['default']
SidebarInset: typeof import('./src/components/ui/sidebar/SidebarInset.vue')['default']
SidebarMenu: typeof import('./src/components/ui/sidebar/SidebarMenu.vue')['default']
SidebarMenuAction: typeof import('./src/components/ui/sidebar/SidebarMenuAction.vue')['default']
SidebarMenuBadge: typeof import('./src/components/ui/sidebar/SidebarMenuBadge.vue')['default']
SidebarMenuButton: typeof import('./src/components/ui/sidebar/SidebarMenuButton.vue')['default']
SidebarMenuButtonChild: typeof import('./src/components/ui/sidebar/SidebarMenuButtonChild.vue')['default']
SidebarMenuItem: typeof import('./src/components/ui/sidebar/SidebarMenuItem.vue')['default']
SidebarMenuSkeleton: typeof import('./src/components/ui/sidebar/SidebarMenuSkeleton.vue')['default']
SidebarMenuSub: typeof import('./src/components/ui/sidebar/SidebarMenuSub.vue')['default']
SidebarMenuSubButton: typeof import('./src/components/ui/sidebar/SidebarMenuSubButton.vue')['default']
SidebarMenuSubItem: typeof import('./src/components/ui/sidebar/SidebarMenuSubItem.vue')['default']
SidebarProvider: typeof import('./src/components/ui/sidebar/SidebarProvider.vue')['default']
SidebarRail: typeof import('./src/components/ui/sidebar/SidebarRail.vue')['default']
SidebarSeparator: typeof import('./src/components/ui/sidebar/SidebarSeparator.vue')['default']
SidebarTrigger: typeof import('./src/components/ui/sidebar/SidebarTrigger.vue')['default']
SignupModal: typeof import('./src/components/auth/SignupModal.vue')['default']
Skeleton: typeof import('./src/components/ui/skeleton/Skeleton.vue')['default']
Slider: typeof import('./src/components/ui/slider/Slider.vue')['default']
Tabs: typeof import('./src/components/ui/tabs/Tabs.vue')['default']
TabsContent: typeof import('./src/components/ui/tabs/TabsContent.vue')['default']
TabsList: typeof import('./src/components/ui/tabs/TabsList.vue')['default']
TabsTrigger: typeof import('./src/components/ui/tabs/TabsTrigger.vue')['default']
ThemeController: typeof import('./src/components/layout/ThemeController.vue')['default']
Tooltip: typeof import('./src/components/ui/tooltip/Tooltip.vue')['default']
TooltipContent: typeof import('./src/components/ui/tooltip/TooltipContent.vue')['default']
TooltipProvider: typeof import('./src/components/ui/tooltip/TooltipProvider.vue')['default']
TooltipTrigger: typeof import('./src/components/ui/tooltip/TooltipTrigger.vue')['default']
}
}

View File

@@ -1,20 +0,0 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/style.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}

View File

@@ -1,4 +0,0 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": ["./**/*"]
}

View File

@@ -1,8 +0,0 @@
import { test, expect } from '@playwright/test';
// See here how to get started:
// https://playwright.dev/docs/intro
test('visits the app root url', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toHaveText('You did it!');
})

1
frontend/env.d.ts vendored
View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,36 +0,0 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginVitest from '@vitest/eslint-plugin'
import pluginPlaywright from 'eslint-plugin-playwright'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
{
...pluginPlaywright.configs['flat/recommended'],
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
},
...pluginOxlint.configs['flat/recommended'],
skipFormatting,
)

View File

@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,76 +0,0 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
"lint:eslint": "eslint . --fix",
"lint": "run-s lint:*",
"format": "prettier --write src/",
"add": "liftkit add"
},
"dependencies": {
"@csstools/normalize.css": "^12.1.1",
"@material/material-color-utilities": "^0.3.0",
"@primeuix/themes": "^1.2.3",
"@primevue/forms": "^4.3.7",
"@primevue/themes": "^4.3.7",
"@vueuse/core": "^13.8.0",
"lodash-es": "^4.17.21",
"pinia": "^3.0.3",
"primeicons": "^7.0.0",
"primevue": "^4.3.7",
"tw-animate-css": "^1.3.7",
"vue": "^3.5.20",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@chainlift/liftkit": "^0.2.0",
"@playwright/test": "^1.55.0",
"@prettier/plugin-oxc": "^0.0.4",
"@primevue/auto-import-resolver": "^4.3.7",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/vite": "^4.1.12",
"@tsconfig/node22": "^22.0.2",
"@types/jsdom": "^21.1.7",
"@types/node": "^24.3.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/eslint-plugin": "^1.3.4",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.34.0",
"eslint-plugin-oxlint": "~1.13.0",
"eslint-plugin-playwright": "^2.2.2",
"eslint-plugin-vue": "~10.4.0",
"jiti": "^2.5.1",
"jsdom": "^26.1.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.13.0",
"postcss": "^8.5.6",
"prettier": "3.6.2",
"tailwindcss": "^4.1.12",
"typescript": "~5.9.2",
"unplugin-vue-components": "^29.0.0",
"vite": "^7.1.3",
"vite-plugin-vue-devtools": "^8.0.1",
"vitest": "^3.2.4",
"vue-tsc": "^3.0.6"
},
"trustedDependencies": [
"@tailwindcss/oxide"
]
}

View File

@@ -1,110 +0,0 @@
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Only on CI systems run the tests headless */
headless: !!process.env.CI,
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
webServer: {
/**
* Use the dev server by default for faster feedback loop.
* Use the preview server on CI for more realistic testing.
* Playwright will re-use the local server if there is already a dev-server running.
*/
command: process.env.CI ? 'npm run preview' : 'npm run dev',
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
})

5520
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
onlyBuiltDependencies:
- vue-demi

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,317 +0,0 @@
<template>
<div class="min-h-screen bg-background text-foreground">
<!-- Authentication Modals -->
<AuthManager
:show="showAuthModal"
:initial-mode="authModalMode"
@close="closeAuthModal"
@success="handleAuthSuccess"
/>
<!-- Header -->
<header class="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="container flex h-16 items-center">
<!-- Logo -->
<div class="flex items-center space-x-2">
<div class="w-8 h-8 bg-gradient-to-br from-primary to-purple-600 rounded-lg flex items-center justify-center">
<span class="text-primary-foreground font-bold text-sm">TW</span>
</div>
<router-link to="/" class="text-lg font-bold text-foreground hover:text-primary transition-colors">
ThrillWiki
</router-link>
</div>
<!-- Navigation -->
<nav class="flex items-center space-x-6 ml-8">
<router-link
to="/parks/"
class="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Parks
</router-link>
<router-link
to="/rides/"
class="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Rides
</router-link>
</nav>
<!-- Header Actions -->
<div class="ml-auto flex items-center gap-2">
<!-- Search -->
<div class="relative hidden md:block">
<i class="pi pi-search absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground"></i>
<InputText
type="search"
placeholder="Search parks, rides..."
class="w-[300px] pl-8"
v-model="searchQuery"
@keyup.enter="handleSearch"
/>
</div>
<!-- Theme Toggle -->
<Button variant="secondary" @click="toggleTheme" class="p-2">
<i v-if="appliedTheme === 'dark'" class="pi pi-sun h-4 w-4"></i>
<i v-else class="pi pi-moon h-4 w-4"></i>
<span class="sr-only">Toggle theme</span>
</Button>
<!-- User Menu -->
<div class="relative">
<Button
variant="secondary"
@click="toggleUserMenu"
ref="userMenuButton"
class="p-2"
>
<i class="pi pi-user h-4 w-4"></i>
<span class="sr-only">User menu</span>
</Button>
<Menu
ref="userMenu"
:model="userMenuItems"
:popup="true"
class="mt-2"
/>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="flex-1">
<router-view />
</main>
<!-- Footer -->
<footer class="bg-card border-t border-border">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="col-span-1">
<div class="flex items-center space-x-2 mb-4">
<div class="w-8 h-8 bg-gradient-to-br from-primary to-purple-600 rounded-lg flex items-center justify-center">
<span class="text-primary-foreground font-bold text-sm">TW</span>
</div>
<h3 class="text-lg font-bold text-foreground">
ThrillWiki
</h3>
</div>
<p class="text-muted-foreground text-sm max-w-xs">
Your ultimate guide to theme parks and thrilling rides around the world.
</p>
</div>
<!-- Explore -->
<div>
<h4 class="font-semibold text-foreground mb-4">Explore</h4>
<ul class="space-y-2">
<li>
<router-link
to="/parks/"
class="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
Parks
</router-link>
</li>
<li>
<router-link
to="/rides/"
class="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
Rides
</router-link>
</li>
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Manufacturers
</a>
</li>
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Operators
</a>
</li>
</ul>
</div>
<!-- Community -->
<div>
<h4 class="font-semibold text-foreground mb-4">Community</h4>
<ul class="space-y-2">
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Join ThrillWiki
</a>
</li>
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Contribute
</a>
</li>
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Community Guidelines
</a>
</li>
</ul>
</div>
<!-- Legal -->
<div>
<h4 class="font-semibold text-foreground mb-4">Legal</h4>
<ul class="space-y-2">
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Privacy Policy
</a>
</li>
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Terms of Service
</a>
</li>
<li>
<a href="#" class="text-muted-foreground hover:text-foreground text-sm transition-colors">
Contact
</a>
</li>
</ul>
</div>
</div>
<!-- Copyright -->
<div class="border-t border-border mt-8 pt-8">
<p class="text-center text-muted-foreground text-sm">
© 2025 ThrillWiki. All rights reserved.
</p>
</div>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTheme } from './composables/useTheme'
import AuthManager from './components/auth/AuthManager.vue'
import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
import Menu from 'primevue/menu'
const router = useRouter()
const searchQuery = ref('')
// Theme management using the useTheme composable
const { appliedTheme, toggleTheme, initializeTheme } = useTheme()
// Authentication modal state
const showAuthModal = ref(false)
const authModalMode = ref<'login' | 'signup'>('login')
// User menu
const userMenu = ref()
const userMenuButton = ref()
// User menu items
const userMenuItems = computed(() => [
{
label: 'Sign In',
icon: 'pi pi-sign-in',
command: () => showLoginModal()
},
{
label: 'Sign Up',
icon: 'pi pi-user-plus',
command: () => showSignupModal()
}
])
// Initialize theme on mount
onMounted(() => {
initializeTheme()
// Listen for sidebar filter changes
window.addEventListener('sidebar-filter-change', handleSidebarFilterChange)
window.addEventListener('show-login', handleShowLogin)
})
// Cleanup event listeners
onUnmounted(() => {
window.removeEventListener('sidebar-filter-change', handleSidebarFilterChange)
window.removeEventListener('show-login', handleShowLogin)
})
// Search functionality
const handleSearch = () => {
if (searchQuery.value.trim()) {
router.push({
name: 'search-results',
query: { q: searchQuery.value.trim() }
})
}
}
// User menu functionality
const toggleUserMenu = (event: Event) => {
userMenu.value.toggle(event)
}
// Sidebar event handlers
const handleSidebarFilterChange = (event: CustomEvent) => {
// Handle filter changes from sidebar
console.log('Sidebar filters changed:', event.detail)
}
const handleShowLogin = () => {
showLoginModal()
}
// Authentication modal functions
const showLoginModal = () => {
authModalMode.value = 'login'
showAuthModal.value = true
}
const showSignupModal = () => {
authModalMode.value = 'signup'
showAuthModal.value = true
}
const closeAuthModal = () => {
showAuthModal.value = false
}
const handleAuthSuccess = (data: { mode: 'login' | 'signup', email: string }) => {
// Handle successful authentication
console.log('Authentication successful!', data)
// This could include redirecting to a dashboard, updating user state, etc.
}
</script>
<style scoped>
/* Additional component-specific styles if needed */
.router-link-active {
@apply text-primary;
}
/* Ensure proper theme integration */
:deep(.p-inputtext) {
@apply bg-background border-border text-foreground;
}
:deep(.p-button) {
@apply transition-colors;
}
:deep(.p-menu) {
@apply bg-background border-border shadow-lg;
}
:deep(.p-menu .p-menuitem-link) {
@apply text-foreground hover:bg-muted;
}
</style>

View File

@@ -1,11 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import App from '../App.vue'
describe('App', () => {
it('mounts renders properly', () => {
const wrapper = mount(App)
expect(wrapper.text()).toContain('You did it!')
})
})

View File

@@ -1,387 +0,0 @@
<template>
<div class="h-full w-64 bg-surface-0 dark:bg-surface-900 border-r border-surface-200 dark:border-surface-700 flex flex-col">
<!-- Header -->
<div class="p-4 border-b border-surface-200 dark:border-surface-700">
<router-link to="/" class="flex items-center gap-3 text-decoration-none">
<div
class="flex aspect-square w-8 h-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-purple-600 text-white"
>
<span class="font-bold text-sm">TW</span>
</div>
<div class="flex-1">
<div class="font-semibold text-surface-900 dark:text-surface-0">ThrillWiki</div>
<div class="text-xs text-surface-600 dark:text-surface-400">Theme Park Database</div>
</div>
</router-link>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<!-- Main Navigation -->
<div>
<div class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-3">Browse</div>
<div class="space-y-1">
<router-link
to="/"
class="flex items-center gap-3 px-3 py-2 rounded-md text-decoration-none transition-colors"
:class="$route.path === '/' ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
>
<i class="pi pi-home text-base"></i>
<span>Home</span>
</router-link>
<div class="space-y-1">
<div class="flex items-center">
<router-link
to="/parks/"
class="flex items-center gap-3 px-3 py-2 rounded-md text-decoration-none transition-colors flex-1"
:class="$route.path.startsWith('/parks') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
>
<i class="pi pi-building text-base"></i>
<span>Parks</span>
</router-link>
<Button
text
size="small"
class="ml-1"
@click="toggleParksSubmenu"
>
<i class="pi pi-plus text-xs"></i>
</Button>
</div>
</div>
<div class="space-y-1">
<div class="flex items-center">
<router-link
to="/rides/"
class="flex items-center gap-3 px-3 py-2 rounded-md text-decoration-none transition-colors flex-1"
:class="$route.path.startsWith('/rides') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
>
<i class="pi pi-bolt text-base"></i>
<span>Rides</span>
</router-link>
<Button
text
size="small"
class="ml-1"
@click="toggleRidesSubmenu"
>
<i class="pi pi-plus text-xs"></i>
</Button>
</div>
</div>
</div>
</div>
<!-- Quick Filters -->
<div>
<div
class="flex items-center justify-between cursor-pointer mb-3"
@click="filtersOpen = !filtersOpen"
>
<div class="text-sm font-medium text-surface-600 dark:text-surface-400">Quick Filters</div>
<i
:class="['pi text-xs transition-transform', filtersOpen ? 'pi-chevron-down' : 'pi-chevron-right']"
></i>
</div>
<div v-show="filtersOpen" class="space-y-1">
<div
class="flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors"
:class="activeFilters.includes('featured') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
@click="applyFilter('featured')"
>
<div class="flex items-center gap-3">
<i class="pi pi-star text-base"></i>
<span>Featured</span>
</div>
<Badge v-if="featuredCount" :value="featuredCount" size="small" />
</div>
<div
class="flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors"
:class="activeFilters.includes('roller_coaster') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
@click="applyFilter('roller_coaster')"
>
<div class="flex items-center gap-3">
<i class="pi pi-angle-double-up text-base"></i>
<span>Roller Coasters</span>
</div>
<Badge v-if="coasterCount" :value="coasterCount" size="small" />
</div>
<div
class="flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors"
:class="activeFilters.includes('water_ride') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
@click="applyFilter('water_ride')"
>
<div class="flex items-center gap-3">
<i class="pi pi-cloud-download text-base"></i>
<span>Water Rides</span>
</div>
<Badge v-if="waterRideCount" :value="waterRideCount" size="small" />
</div>
<div
class="flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors"
:class="activeFilters.includes('family') ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
@click="applyFilter('family')"
>
<div class="flex items-center gap-3">
<i class="pi pi-users text-base"></i>
<span>Family Rides</span>
</div>
<Badge v-if="familyRideCount" :value="familyRideCount" size="small" />
</div>
</div>
</div>
<!-- Recent Activity -->
<div>
<div class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-3">Recent</div>
<div class="space-y-1">
<router-link
v-for="item in recentItems"
:key="item.id"
:to="item.path"
class="flex items-center gap-3 px-3 py-2 rounded-md text-decoration-none transition-colors text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800"
>
<i :class="item.icon" class="text-base"></i>
<span>{{ item.name }}</span>
</router-link>
<div v-if="recentItems.length === 0" class="space-y-2">
<Skeleton height="2rem" />
<Skeleton height="2rem" />
<Skeleton height="2rem" />
</div>
</div>
</div>
<!-- Countries -->
<div>
<div
class="flex items-center justify-between cursor-pointer mb-3"
@click="countriesOpen = !countriesOpen"
>
<div class="text-sm font-medium text-surface-600 dark:text-surface-400">Countries</div>
<i
:class="['pi text-xs transition-transform', countriesOpen ? 'pi-chevron-down' : 'pi-chevron-right']"
></i>
</div>
<div v-show="countriesOpen" class="space-y-1">
<div
v-for="country in countries"
:key="country.code"
class="flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors"
:class="selectedCountry === country.code ? 'bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300' : 'text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'"
@click="filterByCountry(country.code)"
>
<div class="flex items-center gap-3">
<span class="text-base">{{ country.flag }}</span>
<span>{{ country.name }}</span>
</div>
<Badge v-if="country.count" :value="country.count" size="small" />
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="p-4 border-t border-surface-200 dark:border-surface-700">
<div class="relative">
<div
class="flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer transition-colors hover:bg-surface-100 dark:hover:bg-surface-800"
@click="userMenuVisible = true"
>
<Avatar
:label="user?.name?.charAt(0) || 'G'"
class="w-8 h-8"
shape="circle"
/>
<div class="flex-1 text-left">
<div class="font-semibold text-surface-900 dark:text-surface-0 text-sm">{{ user?.name || 'Guest' }}</div>
<div class="text-xs text-surface-600 dark:text-surface-400">{{ user?.email || 'Not signed in' }}</div>
</div>
<i class="pi pi-chevron-up text-xs"></i>
</div>
<Menu
ref="userMenu"
v-model:visible="userMenuVisible"
:model="userMenuItems"
popup
class="w-56"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
// PrimeVue Components
import Button from 'primevue/button'
import Badge from 'primevue/badge'
import Avatar from 'primevue/avatar'
import Menu from 'primevue/menu'
import Skeleton from 'primevue/skeleton'
const route = useRoute()
const router = useRouter()
// State
const filtersOpen = ref(true)
const countriesOpen = ref(false)
const userMenuVisible = ref(false)
const activeFilters = ref<string[]>([])
const selectedCountry = ref<string>('')
// Menu reference
const userMenu = ref()
// Mock user data - replace with actual auth state
const user = ref<{ name: string; email: string; avatar?: string } | null>(null)
// Mock data - replace with actual API calls
const featuredCount = ref(12)
const coasterCount = ref(156)
const waterRideCount = ref(43)
const familyRideCount = ref(89)
const recentItems = ref([
{ id: 1, name: 'Cedar Point', path: '/parks/cedar-point/', icon: 'pi pi-building' },
{ id: 2, name: 'Steel Vengeance', path: '/parks/cedar-point/rides/steel-vengeance/', icon: 'pi pi-bolt' },
{ id: 3, name: 'Magic Kingdom', path: '/parks/magic-kingdom/', icon: 'pi pi-building' },
])
const countries = ref([
{ code: 'US', name: 'United States', flag: '🇺🇸', count: 89 },
{ code: 'UK', name: 'United Kingdom', flag: '🇬🇧', count: 34 },
{ code: 'DE', name: 'Germany', flag: '🇩🇪', count: 28 },
{ code: 'FR', name: 'France', flag: '🇫🇷', count: 22 },
{ code: 'JP', name: 'Japan', flag: '🇯🇵', count: 18 },
{ code: 'CA', name: 'Canada', flag: '🇨🇦', count: 15 },
])
// User menu items
const userMenuItems = computed(() => [
{
separator: true
},
{
label: user.value?.name || 'Guest',
items: [
{
label: user.value?.email || 'Not signed in',
disabled: true
}
]
},
{
separator: true
},
...(user.value ? [] : [{
label: 'Sign In',
icon: 'pi pi-sign-in',
command: () => showLogin()
}]),
...(user.value ? [{
label: 'Upgrade to Pro',
icon: 'pi pi-star',
command: () => console.log('Upgrade to Pro')
}] : []),
{
separator: true
},
{
label: 'Account',
icon: 'pi pi-user',
command: () => console.log('Account')
},
{
label: 'Billing',
icon: 'pi pi-credit-card',
command: () => console.log('Billing')
},
{
label: 'Notifications',
icon: 'pi pi-bell',
command: () => console.log('Notifications')
},
{
separator: true
},
...(user.value ? [{
label: 'Log out',
icon: 'pi pi-sign-out',
command: () => signOut()
}] : [])
])
// Methods
const applyFilter = (filter: string) => {
const index = activeFilters.value.indexOf(filter)
if (index > -1) {
activeFilters.value.splice(index, 1)
} else {
activeFilters.value.push(filter)
}
// Emit filter change event or update store
emitFilterChange()
}
const filterByCountry = (countryCode: string) => {
selectedCountry.value = selectedCountry.value === countryCode ? '' : countryCode
emitFilterChange()
}
const emitFilterChange = () => {
// Emit custom event that parent components can listen to
const event = new CustomEvent('sidebar-filter-change', {
detail: {
filters: activeFilters.value,
country: selectedCountry.value,
},
})
window.dispatchEvent(event)
}
const toggleParksSubmenu = () => {
// Handle parks submenu toggle
console.log('Toggle parks submenu')
}
const toggleRidesSubmenu = () => {
// Handle rides submenu toggle
console.log('Toggle rides submenu')
}
const showLogin = () => {
// Emit login event
const event = new CustomEvent('show-login')
window.dispatchEvent(event)
}
const signOut = () => {
user.value = null
// Handle sign out logic
}
// Initialize component
onMounted(() => {
// Load user data, recent items, etc.
// This would typically come from a store or API
})
</script>
<style scoped>
.text-decoration-none {
text-decoration: none;
}
</style>

View File

@@ -1,236 +0,0 @@
<template>
<Dialog
:visible="isVisible"
:modal="true"
:closable="true"
:style="{ width: '450px' }"
class="p-fluid"
@update:visible="handleVisibilityChange"
>
<template #header>
<h3 class="text-xl font-semibold">
{{ currentMode === 'login' ? 'Sign In' : 'Sign Up' }}
</h3>
</template>
<div class="space-y-4">
<div class="field">
<label for="email" class="block text-sm font-medium mb-2">Email</label>
<InputText
id="email"
v-model="email"
type="email"
placeholder="Enter your email"
:invalid="!!emailError"
/>
<small v-if="emailError" class="p-error">{{ emailError }}</small>
</div>
<div class="field">
<label for="password" class="block text-sm font-medium mb-2">Password</label>
<InputText
id="password"
v-model="password"
type="password"
placeholder="Enter your password"
:invalid="!!passwordError"
/>
<small v-if="passwordError" class="p-error">{{ passwordError }}</small>
</div>
<div v-if="currentMode === 'signup'" class="field">
<label for="confirmPassword" class="block text-sm font-medium mb-2">Confirm Password</label>
<InputText
id="confirmPassword"
v-model="confirmPassword"
type="password"
placeholder="Confirm your password"
:invalid="!!confirmPasswordError"
/>
<small v-if="confirmPasswordError" class="p-error">{{ confirmPasswordError }}</small>
</div>
<div class="flex justify-between items-center pt-4">
<Button
:label="currentMode === 'login' ? 'Sign In' : 'Sign Up'"
@click="handleSubmit"
:loading="isLoading"
class="flex-1 mr-2"
/>
<Button
:label="currentMode === 'login' ? 'Sign Up' : 'Sign In'"
severity="secondary"
@click="toggleMode"
class="flex-1 ml-2"
text
/>
</div>
</div>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
interface Props {
show: boolean
initialMode?: 'login' | 'signup'
}
interface Emits {
(e: 'close'): void
(e: 'success', data: { mode: 'login' | 'signup', email: string }): void
}
const props = withDefaults(defineProps<Props>(), {
initialMode: 'login'
})
const emit = defineEmits<Emits>()
// Reactive state
const currentMode = ref<'login' | 'signup'>(props.initialMode)
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const isLoading = ref(false)
// Validation errors
const emailError = ref('')
const passwordError = ref('')
const confirmPasswordError = ref('')
// Computed
const isVisible = computed({
get: () => props.show,
set: (value: boolean) => {
if (!value) {
emit('close')
}
}
})
// Watch for mode changes
watch(() => props.initialMode, (newMode) => {
currentMode.value = newMode
})
watch(() => props.show, (show) => {
if (show) {
resetForm()
}
})
// Methods
const resetForm = () => {
email.value = ''
password.value = ''
confirmPassword.value = ''
emailError.value = ''
passwordError.value = ''
confirmPasswordError.value = ''
isLoading.value = false
}
const validateForm = () => {
let isValid = true
// Reset errors
emailError.value = ''
passwordError.value = ''
confirmPasswordError.value = ''
// Email validation
if (!email.value) {
emailError.value = 'Email is required'
isValid = false
} else if (!/\S+@\S+\.\S+/.test(email.value)) {
emailError.value = 'Please enter a valid email'
isValid = false
}
// Password validation
if (!password.value) {
passwordError.value = 'Password is required'
isValid = false
} else if (password.value.length < 6) {
passwordError.value = 'Password must be at least 6 characters'
isValid = false
}
// Confirm password validation (only for signup)
if (currentMode.value === 'signup') {
if (!confirmPassword.value) {
confirmPasswordError.value = 'Please confirm your password'
isValid = false
} else if (password.value !== confirmPassword.value) {
confirmPasswordError.value = 'Passwords do not match'
isValid = false
}
}
return isValid
}
const handleSubmit = async () => {
if (!validateForm()) {
return
}
isLoading.value = true
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
// Emit success event
emit('success', {
mode: currentMode.value,
email: email.value
})
// Close modal
emit('close')
} catch (error) {
console.error('Authentication error:', error)
// Handle error (could show toast notification)
} finally {
isLoading.value = false
}
}
const toggleMode = () => {
currentMode.value = currentMode.value === 'login' ? 'signup' : 'login'
// Clear form when switching modes
resetForm()
}
const handleVisibilityChange = (visible: boolean) => {
if (!visible) {
emit('close')
}
}
</script>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'AuthManager'
})
</script>
<style scoped>
.field {
margin-bottom: 1rem;
}
.p-error {
color: var(--red-500);
font-size: 0.875rem;
margin-top: 0.25rem;
}
</style>

View File

@@ -1,103 +0,0 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-50 overflow-y-auto"
@click="closeOnBackdrop && handleBackdropClick"
>
<!-- Backdrop -->
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<!-- Modal Container -->
<div class="flex min-h-full items-center justify-center p-4">
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="relative w-full max-w-md transform overflow-hidden rounded-2xl bg-white dark:bg-gray-800 shadow-2xl transition-all"
@click.stop
>
<!-- Header -->
<div class="flex items-center justify-between p-6 pb-4">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ title }}
</h2>
<button
@click="$emit('close')"
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition-colors"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Content -->
<div class="px-6 pb-6">
<slot />
</div>
</div>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { watch, toRefs, onUnmounted } from 'vue'
interface Props {
show: boolean
title: string
closeOnBackdrop?: boolean
}
const props = withDefaults(defineProps<Props>(), {
closeOnBackdrop: true,
})
const emit = defineEmits<{
close: []
}>()
const handleBackdropClick = (event: MouseEvent) => {
if (props.closeOnBackdrop && event.target === event.currentTarget) {
emit('close')
}
}
// Prevent body scroll when modal is open
const { show } = toRefs(props)
watch(show, (isShown) => {
if (isShown) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
// Clean up on unmount
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>

View File

@@ -1,175 +0,0 @@
<template>
<AuthModal :show="show" title="Reset Password" @close="$emit('close')">
<div v-if="!emailSent">
<p class="text-gray-600 dark:text-gray-400 text-sm mb-6">
Enter your email address and we'll send you a link to reset your password.
</p>
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- Error Messages -->
<div
v-if="authError"
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"
>
<div class="text-sm text-red-600 dark:text-red-400">{{ authError }}</div>
</div>
<!-- Email Field -->
<div>
<label
for="reset-email"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Email Address
</label>
<input
id="reset-email"
v-model="email"
type="email"
required
autocomplete="email"
class="w-full px-4 py-3 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-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Enter your email address"
/>
</div>
<!-- Submit Button -->
<div>
<button
type="submit"
:disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ isLoading ? 'Sending...' : 'Send Reset Link' }}
</button>
</div>
</form>
<!-- Back to Login -->
<div class="mt-6 text-sm text-center">
<button
@click="$emit('close')"
class="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Back to Sign In
</button>
</div>
</div>
<!-- Success State -->
<div v-else class="text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 dark:bg-green-900/20 mb-4"
>
<svg
class="h-6 w-6 text-green-600 dark:text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">Check your email</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-6">
We've sent a password reset link to <strong>{{ email }}</strong>
</p>
<div class="space-y-3">
<button
@click="handleResend"
:disabled="isLoading"
class="w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ isLoading ? 'Sending...' : 'Resend Email' }}
</button>
<button
@click="$emit('close')"
class="w-full py-2 px-4 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Back to Sign In
</button>
</div>
</div>
</AuthModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import AuthModal from './AuthModal.vue'
import { useAuth } from '@/composables/useAuth'
interface Props {
show: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
}>()
const auth = useAuth()
const { isLoading, authError } = auth
const email = ref('')
const emailSent = ref(false)
const handleSubmit = async () => {
try {
await auth.requestPasswordReset(email.value)
emailSent.value = true
} catch (error) {
console.error('Password reset request failed:', error)
}
}
const handleResend = async () => {
try {
await auth.requestPasswordReset(email.value)
} catch (error) {
console.error('Resend failed:', error)
}
}
// Reset state when modal closes
watch(
() => props.show,
(isShown) => {
if (!isShown) {
email.value = ''
emailSent.value = false
auth.clearError()
}
},
)
</script>

View File

@@ -1,237 +0,0 @@
<template>
<AuthModal :show="show" title="Welcome Back" @close="$emit('close')">
<!-- Social Login Buttons -->
<div class="space-y-3 mb-6">
<button
v-for="provider in socialProviders"
:key="provider.id"
@click="loginWithProvider(provider)"
class="w-full flex items-center justify-center px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<component :is="getProviderIcon(provider.id)" class="w-5 h-5 mr-3" />
Continue with {{ provider.name }}
</button>
</div>
<!-- Divider -->
<div v-if="socialProviders.length > 0" class="relative mb-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">
Or continue with email
</span>
</div>
</div>
<!-- Login Form -->
<form @submit.prevent="handleLogin" class="space-y-6">
<!-- Error Messages -->
<div
v-if="authError"
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"
>
<div class="text-sm text-red-600 dark:text-red-400">{{ authError }}</div>
</div>
<!-- Username/Email Field -->
<div>
<label
for="login-username"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Username or Email
</label>
<input
id="login-username"
v-model="form.username"
type="text"
required
autocomplete="username"
class="w-full px-4 py-3 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-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Enter your username or email"
/>
</div>
<!-- Password Field -->
<div>
<label
for="login-password"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Password
</label>
<div class="relative">
<input
id="login-password"
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="current-password"
class="w-full px-4 py-3 pr-12 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-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Enter your password"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<EyeIcon v-if="showPassword" class="h-5 w-5" />
<EyeSlashIcon v-else class="h-5 w-5" />
</button>
</div>
</div>
<!-- Remember Me & Forgot Password -->
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="login-remember"
v-model="form.remember"
type="checkbox"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
/>
<label for="login-remember" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Remember me
</label>
</div>
<button
type="button"
@click="showForgotPassword = true"
class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Forgot password?
</button>
</div>
<!-- Submit Button -->
<div>
<button
type="submit"
:disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ isLoading ? 'Signing in...' : 'Sign In' }}
</button>
</div>
</form>
<!-- Sign Up Link -->
<div class="mt-6 text-sm text-center">
<p class="text-gray-600 dark:text-gray-400">
Don't have an account?
<button
@click="$emit('showSignup')"
class="ml-1 font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Sign up
</button>
</p>
</div>
<!-- Forgot Password Modal -->
<ForgotPasswordModal :show="showForgotPassword" @close="showForgotPassword = false" />
</AuthModal>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/outline'
import AuthModal from './AuthModal.vue'
import ForgotPasswordModal from './ForgotPasswordModal.vue'
import GoogleIcon from '../icons/GoogleIcon.vue'
import DiscordIcon from '../icons/DiscordIcon.vue'
import { useAuth } from '@/composables/useAuth'
import type { SocialAuthProvider } from '@/types'
interface Props {
show: boolean
}
defineProps<Props>()
const emit = defineEmits<{
close: []
showSignup: []
success: []
}>()
const auth = useAuth()
const { isLoading, authError } = auth
const showPassword = ref(false)
const showForgotPassword = ref(false)
const socialProviders = ref<SocialAuthProvider[]>([])
const form = ref({
username: '',
password: '',
remember: false,
})
// Load social providers on mount
onMounted(async () => {
socialProviders.value = await auth.getSocialProviders()
})
const handleLogin = async () => {
try {
await auth.login({
username: form.value.username,
password: form.value.password,
})
// Clear form
form.value = {
username: '',
password: '',
remember: false,
}
emit('success')
emit('close')
} catch (error) {
// Error is handled by the auth composable
console.error('Login failed:', error)
}
}
const loginWithProvider = (provider: SocialAuthProvider) => {
// Redirect to Django allauth provider URL
window.location.href = provider.authUrl
}
const getProviderIcon = (providerId: string) => {
switch (providerId) {
case 'google':
return GoogleIcon
case 'discord':
return DiscordIcon
default:
return 'div'
}
}
</script>

View File

@@ -1,335 +0,0 @@
<template>
<AuthModal :show="show" title="Create Account" @close="$emit('close')">
<!-- Social Login Buttons -->
<div class="space-y-3 mb-6">
<button
v-for="provider in socialProviders"
:key="provider.id"
@click="loginWithProvider(provider)"
class="w-full flex items-center justify-center px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<component :is="getProviderIcon(provider.id)" class="w-5 h-5 mr-3" />
Continue with {{ provider.name }}
</button>
</div>
<!-- Divider -->
<div v-if="socialProviders.length > 0" class="relative mb-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">
Or create account with email
</span>
</div>
</div>
<!-- Signup Form -->
<form @submit.prevent="handleSignup" class="space-y-6">
<!-- Error Messages -->
<div
v-if="authError"
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"
>
<div class="text-sm text-red-600 dark:text-red-400">{{ authError }}</div>
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div>
<label
for="signup-first-name"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
First Name
</label>
<input
id="signup-first-name"
v-model="form.first_name"
type="text"
autocomplete="given-name"
class="w-full px-4 py-3 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-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="First name"
/>
</div>
<div>
<label
for="signup-last-name"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Last Name
</label>
<input
id="signup-last-name"
v-model="form.last_name"
type="text"
autocomplete="family-name"
class="w-full px-4 py-3 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-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Last name"
/>
</div>
</div>
<!-- Username Field -->
<div>
<label
for="signup-username"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Username
</label>
<input
id="signup-username"
v-model="form.username"
type="text"
required
autocomplete="username"
class="w-full px-4 py-3 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-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Choose a username"
/>
</div>
<!-- Email Field -->
<div>
<label
for="signup-email"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Email Address
</label>
<input
id="signup-email"
v-model="form.email"
type="email"
required
autocomplete="email"
class="w-full px-4 py-3 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-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Enter your email"
/>
</div>
<!-- Password Field -->
<div>
<label
for="signup-password"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Password
</label>
<div class="relative">
<input
id="signup-password"
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="new-password"
class="w-full px-4 py-3 pr-12 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-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Create a password"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<EyeIcon v-if="showPassword" class="h-5 w-5" />
<EyeSlashIcon v-else class="h-5 w-5" />
</button>
</div>
</div>
<!-- Confirm Password Field -->
<div>
<label
for="signup-password-confirm"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Confirm Password
</label>
<div class="relative">
<input
id="signup-password-confirm"
v-model="form.password_confirm"
:type="showPasswordConfirm ? 'text' : 'password'"
required
autocomplete="new-password"
class="w-full px-4 py-3 pr-12 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-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
placeholder="Confirm your password"
/>
<button
type="button"
@click="showPasswordConfirm = !showPasswordConfirm"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<EyeIcon v-if="showPasswordConfirm" class="h-5 w-5" />
<EyeSlashIcon v-else class="h-5 w-5" />
</button>
</div>
</div>
<!-- Terms and Privacy -->
<div class="flex items-start">
<input
id="signup-terms"
v-model="form.agreeToTerms"
type="checkbox"
required
class="h-4 w-4 mt-1 text-blue-600 focus:ring-blue-500 border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
/>
<label for="signup-terms" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">
I agree to the
<a
href="/terms"
target="_blank"
class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Terms of Service
</a>
and
<a
href="/privacy"
target="_blank"
class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Privacy Policy
</a>
</label>
</div>
<!-- Submit Button -->
<div>
<button
type="submit"
:disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 714 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ isLoading ? 'Creating Account...' : 'Create Account' }}
</button>
</div>
</form>
<!-- Sign In Link -->
<div class="mt-6 text-sm text-center">
<p class="text-gray-600 dark:text-gray-400">
Already have an account?
<button
@click="$emit('showLogin')"
class="ml-1 font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
>
Sign in
</button>
</p>
</div>
</AuthModal>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/outline'
import AuthModal from './AuthModal.vue'
import GoogleIcon from '../icons/GoogleIcon.vue'
import DiscordIcon from '../icons/DiscordIcon.vue'
import { useAuth } from '@/composables/useAuth'
import type { SocialAuthProvider } from '@/types'
interface Props {
show: boolean
}
defineProps<Props>()
const emit = defineEmits<{
close: []
showLogin: []
success: []
}>()
const auth = useAuth()
const { isLoading, authError } = auth
const showPassword = ref(false)
const showPasswordConfirm = ref(false)
const socialProviders = ref<SocialAuthProvider[]>([])
const form = ref({
first_name: '',
last_name: '',
username: '',
email: '',
password: '',
password_confirm: '',
agreeToTerms: false,
})
// Load social providers on mount
onMounted(async () => {
socialProviders.value = await auth.getSocialProviders()
})
const handleSignup = async () => {
try {
await auth.signup({
first_name: form.value.first_name,
last_name: form.value.last_name,
username: form.value.username,
email: form.value.email,
password: form.value.password,
password_confirm: form.value.password_confirm,
})
// Clear form
form.value = {
first_name: '',
last_name: '',
username: '',
email: '',
password: '',
password_confirm: '',
agreeToTerms: false,
}
emit('success')
emit('close')
} catch (error) {
// Error is handled by the auth composable
console.error('Signup failed:', error)
}
}
const loginWithProvider = (provider: SocialAuthProvider) => {
// Redirect to Django allauth provider URL
window.location.href = provider.login_url
}
const getProviderIcon = (providerId: string) => {
switch (providerId) {
case 'google':
return GoogleIcon
case 'discord':
return DiscordIcon
default:
return 'div'
}
}
</script>

View File

@@ -1,87 +0,0 @@
[data-lk-component='button'] {
/* DEFAULTS */
--button-font-size: var(--body-font-size);
--button-line-height: var(--lk-halfstep) !important;
--button-padX: var(--button-font-size);
--button-padY: calc(
var(--button-font-size) * calc(var(--lk-halfstep) / var(--lk-size-xl-unitless))
);
--button-padX-sideWithIcon: calc(var(--button-font-size) / var(--lk-wholestep));
--button-gap: calc(var(--button-padY) / var(--lk-eighthstep));
cursor: pointer;
display: inline-flex;
vertical-align: middle;
border: 1px solid rgba(0, 0, 0, 0);
border-radius: 100em;
position: relative;
text-decoration: none;
white-space: pre;
word-break: keep-all;
overflow: hidden;
padding: var(--button-padY) 1em;
font-weight: 500;
font-size: var(--button-font-size);
line-height: var(--button-line-height);
font-family: inherit;
}
/* SIZE VARIANTS */
[data-lk-button-size='sm'] {
--button-font-size: var(--subheading-font-size);
}
[data-lk-button-size='lg'] {
--button-font-size: var(--title3-font-size);
}
/* ICON-BASED PADDING ADJUSTMENTS */
[data-lk-component='button']:has([data-lk-icon-position='start']) {
padding-left: var(--button-padX-sideWithIcon);
padding-right: var(--button-padX);
}
[data-lk-component='button']:has([data-lk-icon-position='end']) {
padding-left: 1em;
padding-right: var(--button-padX-sideWithIcon);
}
[data-lk-component='button']:has([data-lk-icon-position='start']):has(
[data-lk-icon-position='end']
) {
padding-left: var(--button-padX-sideWithIcon);
padding-right: var(--button-padX-sideWithIcon);
}
/* CONTENT WRAPPER */
[data-lk-button-content-wrap='true'] {
display: inline-flex;
align-items: center;
gap: var(--button-gap);
}
/* TODO: Remove entirely */
/* [data-lk-component="button"] div:has(> [data-lk-component="icon"]) {
width: calc(1em * var(--lk-halfstep));
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
} */
/* ICON VERTICAL OPTICAL ALIGNMENTS */
[data-lk-button-optic-icon-shift='true'] div:has(> [data-lk-component='icon']) {
margin-top: calc(-1 * calc(1em * var(--lk-quarterstep-dec)));
}
/* STYLE VARIANTS */
[data-lk-button-variant='text'] {
background: transparent !important;
}
[data-lk-button-variant='outline'] {
background: transparent !important;
border: 1px solid var(--lk-outlinevariant);
}

View File

@@ -1,137 +0,0 @@
'use client'
import { useMemo } from 'react'
import { propsToDataAttrs } from '@/lib/utilities'
import { getOnToken } from '@/lib/colorUtils'
import { IconName } from 'lucide-react/dynamic'
import '@/components/button/button.css'
import StateLayer from '@/components/state-layer'
import { LkStateLayerProps } from '@/components/state-layer'
import Icon from '@/components/icon'
export interface LkButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label?: string
variant?: 'fill' | 'outline' | 'text'
color?: LkColorWithOnToken
size?: 'sm' | 'md' | 'lg'
material?: string
startIcon?: IconName
endIcon?: IconName
opticIconShift?: boolean
modifiers?: string
stateLayerOverride?: LkStateLayerProps // Optional override for state layer properties
}
/**
* A customizable button component with support for various visual styles, sizes, and icons.
*
* @param props - The button component props
* @param props.label - The text content displayed inside the button. Defaults to "Button"
* @param props.variant - The visual style variant of the button. Defaults to "fill"
* @param props.color - The color theme of the button. Defaults to "primary"
* @param props.size - The size of the button (sm, md, lg). Defaults to "md"
* @param props.startIcon - Optional icon element to display at the start of the button
* @param props.endIcon - Optional icon element to display at the end of the button
* @param props.restProps - Additional props to be spread to the underlying button element
* @param props.opticIconShift - Boolean to control optical icon alignment on the y-axis. Defaults to true. Pulls icons up slightly.
* @param props.modifiers - Additional class names to concatenate onto the button's default class list
* @param props.stateLayerOverride - Optional override for state layer properties, allowing customization of the state layer's appearance
*
* @returns A styled button element with optional start/end icons and a state layer overlay
*
* @example
* ```tsx
* <Button
* label="Click me"
* variant="outline"
* color="secondary"
* size="lg"
* startIcon={<ChevronIcon />}
* />
* ```
*/
export default function Button({
label = 'Button',
variant = 'fill',
color = 'primary',
size = 'md',
startIcon,
endIcon,
opticIconShift = true,
modifiers,
stateLayerOverride,
...restProps
}: LkButtonProps) {
const lkButtonAttrs = useMemo(
() => propsToDataAttrs({ variant, color, size, startIcon, endIcon, opticIconShift }, 'button'),
[variant, color, size, startIcon, endIcon, opticIconShift],
)
const onColorToken = getOnToken(color) as LkColor
// Define different base color classes based on variant
let baseButtonClasses = ''
switch (variant) {
case 'fill':
baseButtonClasses = `bg-${color} color-${onColorToken}`
break
case 'outline':
case 'text':
baseButtonClasses = `color-${color}`
break
default:
baseButtonClasses = `bg-${color} color-${onColorToken}`
break
}
if (modifiers) {
baseButtonClasses += ` ${modifiers}`
}
/**Determine state layer props dynamically */
function getLocalStateLayerProps() {
if (stateLayerOverride) {
return stateLayerOverride
} else {
return {
bgColor: variant === 'fill' ? onColorToken : color,
}
}
}
const localStateLayerProps: LkStateLayerProps = getLocalStateLayerProps()
return (
<button
{...lkButtonAttrs}
{...restProps}
type="button"
data-lk-component="button"
className={`${baseButtonClasses} ${modifiers || ''}`}
>
<div data-lk-button-content-wrap="true">
{startIcon && (
<div data-lk-icon-position="start">
<Icon
name={startIcon}
color={variant === 'fill' ? onColorToken : color}
data-lk-icon-position="start"
></Icon>
</div>
)}
<span data-lk-button-child="button-text">{label ?? 'Button'}</span>
{endIcon && (
<div data-lk-icon-position="end">
<Icon
name={endIcon}
color={variant === 'fill' ? onColorToken : color}
data-lk-icon-position="end"
></Icon>
</div>
)}
</div>
<StateLayer {...localStateLayerProps} />
</button>
)
}

View File

@@ -1,173 +0,0 @@
<template>
<div class="space-y-4">
<div class="text-center">
<div class="mb-4">
<div
class="mx-auto w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center"
>
<svg
class="h-6 w-6 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
</div>
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Sign in to contribute
</h4>
<p class="text-gray-600 dark:text-gray-400 mb-6">
You need to be signed in to add "{{ searchTerm }}" to ThrillWiki's database. Join our
community of theme park enthusiasts!
</p>
</div>
<!-- Benefits List -->
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 space-y-3">
<h5 class="font-medium text-gray-900 dark:text-white flex items-center gap-2">
<svg
class="h-5 w-5 text-green-600 dark:text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
What you can do:
</h5>
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li class="flex items-start gap-2">
<svg
class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
Add new parks, rides, and companies
</li>
<li class="flex items-start gap-2">
<svg
class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Edit and improve existing entries
</li>
<li class="flex items-start gap-2">
<svg
class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
Save your favorite places
</li>
<li class="flex items-start gap-2">
<svg
class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-6a2 2 0 012-2h8z"
/>
</svg>
Share reviews and experiences
</li>
</ul>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3">
<button
@click="handleLogin"
class="flex-1 bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Sign In
</button>
<button
@click="handleSignup"
class="flex-1 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 px-6 py-3 rounded-lg font-semibold border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Create Account
</button>
</div>
<!-- Alternative Options -->
<div class="text-center pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Or continue exploring ThrillWiki</p>
<button
@click="handleBrowseExisting"
class="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium transition-colors"
>
Browse existing entries →
</button>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
searchTerm: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
login: []
signup: []
browse: []
}>()
const handleLogin = () => {
emit('login')
}
const handleSignup = () => {
emit('signup')
}
const handleBrowseExisting = () => {
emit('browse')
}
</script>

View File

@@ -1,185 +0,0 @@
<template>
<div
class="group relative bg-gray-50 dark:bg-gray-700 rounded-lg p-4 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors cursor-pointer border border-gray-200 dark:border-gray-600"
@click="handleSelect"
>
<!-- Entity Type Badge -->
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span :class="entityTypeBadgeClasses">
<component :is="entityIcon" class="h-4 w-4" />
{{ entityTypeLabel }}
</span>
<span
v-if="suggestion.confidence_score"
:class="confidenceClasses"
class="text-xs px-2 py-1 rounded-full font-medium"
>
{{ confidenceLabel }}
</span>
</div>
<!-- Select Arrow -->
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
<svg
class="h-5 w-5 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
<!-- Entity Name -->
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{{ suggestion.name }}
</h4>
<!-- Entity Details -->
<div class="space-y-2">
<!-- Location for Parks -->
<div
v-if="suggestion.entity_type === 'park' && suggestion.location"
class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{{ suggestion.location }}
</div>
<!-- Park for Rides -->
<div
v-if="suggestion.entity_type === 'ride' && suggestion.park_name"
class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
At {{ suggestion.park_name }}
</div>
<!-- Description -->
<p
v-if="suggestion.description"
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"
>
{{ suggestion.description }}
</p>
<!-- Match Reason -->
<div
v-if="suggestion.match_reason"
class="text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 px-2 py-1 rounded"
>
{{ suggestion.match_reason }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { EntitySuggestion } from '../../services/api'
interface Props {
suggestion: EntitySuggestion
}
const props = defineProps<Props>()
const emit = defineEmits<{
select: [suggestion: EntitySuggestion]
}>()
// Entity type configurations
const entityTypeConfig = {
park: {
label: 'Park',
badgeClass: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
icon: 'BuildingStorefrontIcon',
},
ride: {
label: 'Ride',
badgeClass: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
icon: 'SparklesIcon',
},
company: {
label: 'Company',
badgeClass: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
icon: 'BuildingOfficeIcon',
},
}
// Computed properties
const entityTypeLabel = computed(
() => entityTypeConfig[props.suggestion.entity_type]?.label || 'Entity',
)
const entityTypeBadgeClasses = computed(() => {
const baseClasses = 'inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium'
const typeClasses =
entityTypeConfig[props.suggestion.entity_type]?.badgeClass ||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
return `${baseClasses} ${typeClasses}`
})
const confidenceLabel = computed(() => {
const score = props.suggestion.confidence_score
if (score >= 0.8) return 'High Match'
if (score >= 0.6) return 'Good Match'
if (score >= 0.4) return 'Possible Match'
return 'Low Match'
})
const confidenceClasses = computed(() => {
const score = props.suggestion.confidence_score
if (score >= 0.8) return 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
if (score >= 0.6) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
if (score >= 0.4) return 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300'
return 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
})
// Simple icon components
const entityIcon = computed(() => {
const type = props.suggestion.entity_type
// Return appropriate icon component name or a default SVG
if (type === 'park') return 'BuildingStorefrontIcon'
if (type === 'ride') return 'SparklesIcon'
if (type === 'company') return 'BuildingOfficeIcon'
return 'QuestionMarkCircleIcon'
})
// Event handlers
const handleSelect = () => {
emit('select', props.suggestion)
}
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -1,235 +0,0 @@
<template>
<EntitySuggestionModal
:show="showModal"
:search-term="searchTerm"
:suggestions="suggestions"
:is-authenticated="isAuthenticated"
@close="handleClose"
@select-suggestion="handleSuggestionSelect"
@add-entity="handleAddEntity"
@login="handleLogin"
@signup="handleSignup"
/>
<!-- Authentication Manager -->
<AuthManager
:show="showAuthModal"
:initial-mode="authMode"
@close="handleAuthClose"
@success="handleAuthSuccess"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch, readonly } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '../../composables/useAuth'
import { ThrillWikiApi, type EntitySuggestion } from '../../services/api'
import EntitySuggestionModal from './EntitySuggestionModal.vue'
import AuthManager from '../auth/AuthManager.vue'
interface Props {
searchTerm: string
show?: boolean
entityTypes?: string[]
parkContext?: string
maxSuggestions?: number
}
const props = withDefaults(defineProps<Props>(), {
show: false,
entityTypes: () => ['park', 'ride', 'company'],
maxSuggestions: 5,
})
const emit = defineEmits<{
close: []
entitySelected: [entity: EntitySuggestion]
entityAdded: [entityType: string, name: string]
error: [message: string]
}>()
// Dependencies
const router = useRouter()
const { user, isAuthenticated, login, signup } = useAuth()
const api = new ThrillWikiApi()
// Reactive state
const showModal = ref(props.show)
const suggestions = ref<EntitySuggestion[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// Authentication modal state
const showAuthModal = ref(false)
const authMode = ref<'login' | 'signup'>('login')
// Computed properties
const hasValidSearchTerm = computed(() => {
return props.searchTerm && props.searchTerm.trim().length > 0
})
// Watch for prop changes
watch(
() => props.show,
(newShow) => {
showModal.value = newShow
if (newShow && hasValidSearchTerm.value) {
performFuzzySearch()
}
},
{ immediate: true },
)
watch(
() => props.searchTerm,
(newTerm) => {
if (showModal.value && newTerm && newTerm.trim().length > 0) {
performFuzzySearch()
}
},
)
// Methods
const performFuzzySearch = async () => {
if (!hasValidSearchTerm.value) {
suggestions.value = []
return
}
loading.value = true
error.value = null
try {
const response = await api.entitySearch.fuzzySearch({
query: props.searchTerm.trim(),
entityTypes: props.entityTypes,
parkContext: props.parkContext,
maxResults: props.maxSuggestions,
minConfidence: 0.3,
})
suggestions.value = response.suggestions || []
} catch (err) {
console.error('Fuzzy search failed:', err)
error.value = 'Failed to search for similar entities. Please try again.'
suggestions.value = []
emit('error', error.value)
} finally {
loading.value = false
}
}
const handleClose = () => {
showModal.value = false
emit('close')
}
const handleSuggestionSelect = (suggestion: EntitySuggestion) => {
emit('entitySelected', suggestion)
handleClose()
// Navigate to the selected entity
navigateToEntity(suggestion)
}
const handleAddEntity = async (entityType: string, name: string) => {
try {
// Emit event for parent to handle
emit('entityAdded', entityType, name)
// For now, just close the modal
// In a real implementation, this might navigate to an add entity form
handleClose()
// You could also show a success message here
console.log(`Entity creation initiated: ${entityType} - ${name}`)
} catch (err) {
console.error('Failed to initiate entity creation:', err)
error.value = 'Failed to initiate entity creation. Please try again.'
emit('error', error.value)
}
}
const handleLogin = () => {
authMode.value = 'login'
showAuthModal.value = true
}
const handleSignup = () => {
authMode.value = 'signup'
showAuthModal.value = true
}
// Authentication modal handlers
const handleAuthClose = () => {
showAuthModal.value = false
}
const handleAuthSuccess = () => {
showAuthModal.value = false
// Optionally refresh suggestions now that user is authenticated
if (hasValidSearchTerm.value && showModal.value) {
performFuzzySearch()
}
}
const navigateToEntity = (entity: EntitySuggestion) => {
try {
let route = ''
switch (entity.entity_type) {
case 'park':
route = `/parks/${entity.slug}`
break
case 'ride':
if (entity.park_slug) {
route = `/parks/${entity.park_slug}/rides/${entity.slug}`
} else {
route = `/rides/${entity.slug}`
}
break
case 'company':
route = `/companies/${entity.slug}`
break
default:
console.warn(`Unknown entity type: ${entity.entity_type}`)
return
}
router.push(route)
} catch (err) {
console.error('Failed to navigate to entity:', err)
error.value = 'Failed to navigate to the selected entity.'
emit('error', error.value)
}
}
// Public methods for external control
const show = () => {
showModal.value = true
if (hasValidSearchTerm.value) {
performFuzzySearch()
}
}
const hide = () => {
showModal.value = false
}
const refresh = () => {
if (showModal.value && hasValidSearchTerm.value) {
performFuzzySearch()
}
}
// Expose methods for parent components
defineExpose({
show,
hide,
refresh,
suggestions: readonly(suggestions),
loading: readonly(loading),
error: readonly(error),
})
</script>

View File

@@ -1,214 +0,0 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-50 overflow-y-auto"
@click="closeOnBackdrop && handleBackdropClick"
>
<!-- Backdrop -->
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<!-- Modal Container -->
<div class="flex min-h-full items-center justify-center p-4">
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="relative w-full max-w-2xl transform overflow-hidden rounded-2xl bg-white dark:bg-gray-800 shadow-2xl transition-all"
@click.stop
>
<!-- Header -->
<div
class="flex items-center justify-between p-6 pb-4 border-b border-gray-200 dark:border-gray-700"
>
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Entity Not Found</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
We couldn't find "{{ searchTerm }}" but here are some suggestions
</p>
</div>
<button
@click="$emit('close')"
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition-colors"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Content -->
<div class="px-6 pb-6">
<!-- Suggestions Section -->
<div v-if="suggestions.length > 0" class="mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Did you mean one of these?
</h3>
<div class="space-y-3">
<EntitySuggestionCard
v-for="suggestion in suggestions"
:key="`${suggestion.entity_type}-${suggestion.slug}`"
:suggestion="suggestion"
@select="handleSuggestionSelect"
/>
</div>
</div>
<!-- No Suggestions / Add New Section -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Can't find what you're looking for?
</h3>
<!-- Authenticated User - Add Entity -->
<div v-if="isAuthenticated" class="space-y-4">
<p class="text-gray-600 dark:text-gray-400">
You can help improve ThrillWiki by adding this entity to our database.
</p>
<div class="flex gap-3">
<button
@click="handleAddEntity('park')"
:disabled="loading"
class="flex-1 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add as Park
</button>
<button
@click="handleAddEntity('ride')"
:disabled="loading"
class="flex-1 bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add as Ride
</button>
<button
@click="handleAddEntity('company')"
:disabled="loading"
class="flex-1 bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add as Company
</button>
</div>
</div>
<!-- Unauthenticated User - Auth Prompt -->
<AuthPrompt
v-else
:search-term="searchTerm"
@login="handleLogin"
@signup="handleSignup"
/>
</div>
</div>
<!-- Loading Overlay -->
<div
v-if="loading"
class="absolute inset-0 bg-white/80 dark:bg-gray-800/80 flex items-center justify-center rounded-2xl"
>
<div class="flex items-center gap-3">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span class="text-gray-700 dark:text-gray-300">{{ loadingMessage }}</span>
</div>
</div>
</div>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, toRefs, onUnmounted, watch } from 'vue'
import type { EntitySuggestion } from '../../services/api'
import EntitySuggestionCard from './EntitySuggestionCard.vue'
import AuthPrompt from './AuthPrompt.vue'
interface Props {
show: boolean
searchTerm: string
suggestions: EntitySuggestion[]
isAuthenticated: boolean
closeOnBackdrop?: boolean
}
const props = withDefaults(defineProps<Props>(), {
closeOnBackdrop: true,
})
const emit = defineEmits<{
close: []
selectSuggestion: [suggestion: EntitySuggestion]
addEntity: [entityType: string, name: string]
login: []
signup: []
}>()
// Loading state
const loading = ref(false)
const loadingMessage = ref('')
const handleBackdropClick = (event: MouseEvent) => {
if (props.closeOnBackdrop && event.target === event.currentTarget) {
emit('close')
}
}
const handleSuggestionSelect = (suggestion: EntitySuggestion) => {
emit('selectSuggestion', suggestion)
}
const handleAddEntity = async (entityType: string) => {
loading.value = true
loadingMessage.value = `Adding ${entityType}...`
try {
emit('addEntity', entityType, props.searchTerm)
} finally {
loading.value = false
loadingMessage.value = ''
}
}
const handleLogin = () => {
emit('login')
}
const handleSignup = () => {
emit('signup')
}
// Prevent body scroll when modal is open
const { show } = toRefs(props)
watch(show, (isShown) => {
if (isShown) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
// Clean up on unmount
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>

View File

@@ -1,7 +0,0 @@
// Entity suggestion components
export { default as EntitySuggestionModal } from './EntitySuggestionModal.vue'
export { default as EntitySuggestionCard } from './EntitySuggestionCard.vue'
export { default as AuthPrompt } from './AuthPrompt.vue'
// Main integration component
export { default as EntitySuggestionManager } from './EntitySuggestionManager.vue'

View File

@@ -1,125 +0,0 @@
<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 -->
<i :class="`pi ${getIconClass(icon)} 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`"
>
<i class="pi pi-times 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'
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)
})
// Icon mapping function
const getIconClass = (iconName: string): string => {
const iconMap: Record<string, string> = {
'filter': 'pi-filter',
'search': 'pi-search',
'calendar': 'pi-calendar',
'map-pin': 'pi-map-marker',
'tag': 'pi-tag',
'users': 'pi-users',
'building': 'pi-building',
'activity': 'pi-chart-line',
'globe': 'pi-globe',
'x': 'pi-times',
'check': 'pi-check',
'plus': 'pi-plus',
'minus': 'pi-minus',
}
return iconMap[iconName] || 'pi-circle'
}
</script>
<style scoped>
@reference "tailwindcss";
.active-filter-chip {
@apply transition-all duration-200;
}
.active-filter-chip:hover {
@apply shadow-sm;
}
</style>

View File

@@ -1,408 +0,0 @@
<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>
@reference "tailwindcss";
.date-input {
@apply transition-colors duration-200;
}
.date-input:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
.date-input:disabled {
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
}
/* Custom date picker styles */
.date-input::-webkit-calendar-picker-indicator {
opacity: 0;
position: absolute;
right: 0;
width: 20px;
height: 20px;
cursor: pointer;
}
.date-input::-webkit-datetime-edit {
@apply text-gray-900 dark:text-white;
}
.date-input::-webkit-datetime-edit-fields-wrapper {
@apply text-gray-900 dark:text-white;
}
.date-input::-webkit-datetime-edit-text {
@apply text-gray-500 dark:text-gray-400;
}
.date-input[type='date']::-webkit-input-placeholder {
@apply text-gray-400 dark:text-gray-500;
}
/* Firefox */
.date-input[type='date']::-moz-placeholder {
@apply text-gray-400 dark:text-gray-500;
}
/* Edge */
.date-input[type='date']::-ms-input-placeholder {
@apply text-gray-400 dark:text-gray-500;
}
</style>

View File

@@ -1,71 +0,0 @@
<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>
<i
:class="[
'w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform',
isExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'
]"
/>
</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">
interface Props {
id: string
title: string
isExpanded: boolean
}
defineProps<Props>()
defineEmits<{
toggle: []
}>()
</script>
<style scoped>
@reference "tailwindcss";
.filter-section {
@apply border-b border-gray-100 dark:border-gray-700;
}
.section-header {
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset;
}
.section-content {
@apply bg-gray-50 dark:bg-gray-800;
}
</style>

View File

@@ -1,209 +0,0 @@
<template>
<Card
class="preset-item group cursor-pointer transition-all duration-200 hover:shadow-md border-2"
:class="{
'bg-primary/10 border-primary': isActive,
'cursor-pointer': !disabled,
'opacity-50 cursor-not-allowed': disabled,
}"
@click="!disabled && $emit('select')"
>
<template #content>
<div class="p-3">
<div class="flex items-center justify-between">
<!-- Preset info -->
<div class="flex-1 min-w-0">
<!-- Name and badges -->
<div class="flex items-center gap-2 mb-1">
<h3 class="text-base font-medium truncate">
{{ preset.name }}
</h3>
<Badge
v-if="isDefault"
severity="secondary"
class="text-xs"
>
Default
</Badge>
<Badge
v-if="isGlobal"
severity="success"
class="text-xs bg-green-100 dark:bg-green-800 text-green-700 dark:text-green-200 border-green-200 dark:border-green-700"
>
Global
</Badge>
</div>
<!-- Description -->
<p v-if="preset.description" class="text-sm truncate mb-2 text-muted-foreground">
{{ preset.description }}
</p>
<!-- Filter count and last used -->
<div class="flex items-center gap-4 text-xs text-muted-foreground">
<span class="flex items-center gap-1">
<i class="pi pi-filter w-3 h-3" />
{{ filterCount }} {{ filterCount === 1 ? 'filter' : 'filters' }}
</span>
<span v-if="preset.lastUsed" class="flex items-center gap-1">
<i class="pi pi-clock 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')"
text
size="small"
class="p-1 h-auto w-auto"
:class="{
'text-yellow-500': preset.isFavorite,
'text-muted-foreground': !preset.isFavorite,
}"
:aria-label="preset.isFavorite ? 'Remove from favorites' : 'Add to favorites'"
>
<i class="pi pi-star-fill w-4 h-4" :class="{ 'text-yellow-500': preset.isFavorite }"></i>
</Button>
<!-- More actions menu -->
<Menu v-if="showActions" ref="menu" :model="menuItems" :popup="true">
<template #start>
<Button
text
size="small"
class="p-1 h-auto w-auto text-muted-foreground"
:aria-label="'More actions for ' + preset.name"
@click.stop="toggleMenu"
>
<i class="pi pi-ellipsis-v w-4 h-4"></i>
</Button>
</template>
</Menu>
</div>
</div>
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import Card from 'primevue/card'
import Badge from 'primevue/badge'
import Button from 'primevue/button'
import Menu from 'primevue/menu'
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,
})
const emit = defineEmits<{
select: []
'toggle-favorite': []
rename: []
duplicate: []
delete: []
}>()
// Menu ref and items
const menu = ref()
const menuItems = computed(() => {
const items = []
if (!props.isDefault) {
items.push({
label: 'Rename',
icon: 'pi pi-pencil',
command: () => emit('rename')
})
}
items.push({
label: 'Duplicate',
icon: 'pi pi-copy',
command: () => emit('duplicate')
})
if (!props.isDefault) {
items.push({
separator: true
})
items.push({
label: 'Delete',
icon: 'pi pi-trash',
class: 'text-red-500',
command: () => emit('delete')
})
}
return items
})
const toggleMenu = (event: Event) => {
menu.value.toggle(event)
}
// 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()
}
}
</script>

View File

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

View File

@@ -1,935 +0,0 @@
<template>
<div class="ride-filter-sidebar w-80 flex-shrink-0 h-screen sticky top-16 overflow-y-auto
bg-gradient-to-br from-surface-0 via-surface-25 to-primary-50
dark:from-surface-950 dark:via-surface-900 dark:to-surface-800
border-r border-primary-200 dark:border-primary-800
shadow-lg shadow-primary-100/20 dark:shadow-primary-900/30">
<!-- Filter Header -->
<div class="filter-header">
<div class="flex items-center justify-between p-6
bg-gradient-to-r from-primary-500 via-primary-600 to-purple-600
dark:from-primary-600 dark:via-primary-700 dark:to-purple-700
border-b border-primary-300 dark:border-primary-600">
<div class="flex items-center gap-3">
<i class="pi pi-filter text-xl text-primary-50 drop-shadow-sm" />
<h2 class="text-xl font-bold text-primary-50 drop-shadow-sm">Filters</h2>
<Badge
v-if="activeFiltersCount > 0"
class="bg-surface-0 text-primary-700 font-bold text-sm px-2 py-1
border border-primary-300 shadow-lg"
:value="activeFiltersCount.toString()"
/>
</div>
<!-- Mobile close button -->
<Button
v-if="isMobile"
@click="closeFilterForm"
text
size="small"
class="lg:hidden text-primary-50 hover:bg-primary-400/20"
aria-label="Close filters"
>
<i class="pi pi-times text-xl" />
</Button>
</div>
<!-- Filter Actions -->
<div class="flex items-center gap-3 p-4
bg-gradient-to-r from-surface-50 via-surface-25 to-primary-50
dark:from-surface-800 dark:via-surface-850 dark:to-surface-800
border-b border-primary-200 dark:border-primary-700">
<Button
@click="applyFilters"
:disabled="!hasUnsavedChanges"
class="flex-1 bg-gradient-to-r from-primary-500 to-primary-600
hover:from-primary-600 hover:to-primary-700
text-primary-50 font-semibold shadow-lg
border border-primary-300 dark:border-primary-600"
>
Apply Filters
</Button>
<Button
@click="clearAllFilters"
:disabled="!hasActiveFilters"
class="bg-gradient-to-r from-slate-100 to-slate-200
dark:from-slate-700 dark:to-slate-600
text-slate-700 dark:text-slate-200 font-medium
border border-slate-300 dark:border-slate-600
hover:from-slate-200 hover:to-slate-300
dark:hover:from-slate-600 dark:hover:to-slate-500"
>
Clear All
</Button>
<Button
@click="expandAllSections"
text
size="small"
title="Expand all sections"
class="text-primary-600 dark:text-primary-400
hover:bg-primary-100 dark:hover:bg-primary-800/50"
>
<i class="pi pi-chevron-down" />
</Button>
</div>
</div>
<!-- Filter Sections -->
<div class="filter-sections flex-1 overflow-y-auto">
<!-- Search Section -->
<div class="filter-section border-b border-primary-200 dark:border-primary-700">
<button
@click="toggleSection('search')"
class="w-full flex items-center justify-between p-4 text-left
bg-gradient-to-r from-surface-25 to-surface-50
dark:from-surface-875 dark:to-surface-850
hover:from-primary-50 hover:to-primary-100
dark:hover:from-primary-900/30 dark:hover:to-primary-800/30
transition-all duration-300"
>
<div class="flex items-center gap-3">
<i class="pi pi-search text-primary-600 dark:text-primary-400"></i>
<h3 class="text-sm font-semibold text-surface-800 dark:text-surface-200">Search</h3>
</div>
<i :class="['pi text-primary-500 dark:text-primary-400 transition-transform duration-300',
formState.expandedSections.search ? 'pi-chevron-up rotate-180' : 'pi-chevron-down']" />
</button>
<div v-if="formState.expandedSections.search" class="p-4 pt-0
bg-gradient-to-br from-surface-25 to-primary-25
dark:from-surface-875 dark:to-surface-850">
<InputText
v-model="filters.search"
placeholder="Search rides..."
class="w-full bg-surface-0 dark:bg-surface-800
border-primary-200 dark:border-primary-700
focus:border-primary-500 dark:focus:border-primary-400
focus:ring-2 focus:ring-primary-200 dark:focus:ring-primary-800
text-surface-800 dark:text-surface-200"
@input="updateFilter('search', $event.target.value)"
/>
</div>
</div>
<!-- Basic Filters Section -->
<div class="filter-section border-b border-primary-200 dark:border-primary-700">
<button
@click="toggleSection('basic')"
class="w-full flex items-center justify-between p-4 text-left
bg-gradient-to-r from-surface-25 to-surface-50
dark:from-surface-875 dark:to-surface-850
hover:from-primary-50 hover:to-primary-100
dark:hover:from-primary-900/30 dark:hover:to-primary-800/30
transition-all duration-300"
>
<div class="flex items-center gap-3">
<i class="pi pi-filter text-primary-600 dark:text-primary-400"></i>
<h3 class="text-sm font-semibold text-surface-800 dark:text-surface-200">Basic Filters</h3>
</div>
<i :class="['pi text-primary-500 dark:text-primary-400 transition-transform duration-300',
formState.expandedSections.basic ? 'pi-chevron-up rotate-180' : 'pi-chevron-down']" />
</button>
<div v-if="formState.expandedSections.basic" class="p-4 pt-0 space-y-4
bg-gradient-to-br from-surface-25 to-primary-25
dark:from-surface-875 dark:to-surface-850">
<div>
<label class="block text-sm font-semibold text-surface-700 dark:text-surface-300 mb-2">Category</label>
<MultiSelect
v-model="filters.category"
:options="filterOptions?.categories || []"
optionLabel="label"
optionValue="value"
placeholder="Select categories"
class="w-full bg-surface-0 dark:bg-surface-800
border-primary-200 dark:border-primary-700
focus:border-primary-500 dark:focus:border-primary-400"
@change="updateFilter('category', $event.value)"
/>
</div>
<div>
<label class="block text-sm font-semibold text-surface-700 dark:text-surface-300 mb-2">Status</label>
<MultiSelect
v-model="filters.status"
:options="filterOptions?.statuses || []"
optionLabel="label"
optionValue="value"
placeholder="Select statuses"
class="w-full bg-surface-0 dark:bg-surface-800
border-primary-200 dark:border-primary-700
focus:border-primary-500 dark:focus:border-primary-400"
@change="updateFilter('status', $event.value)"
/>
</div>
</div>
</div>
<!-- Manufacturer Section -->
<div class="filter-section border-b border-primary-200 dark:border-primary-700">
<button
@click="toggleSection('manufacturer')"
class="w-full flex items-center justify-between p-4 text-left
bg-gradient-to-r from-surface-25 to-surface-50
dark:from-surface-875 dark:to-surface-850
hover:from-primary-50 hover:to-primary-100
dark:hover:from-primary-900/30 dark:hover:to-primary-800/30
transition-all duration-300"
>
<div class="flex items-center gap-3">
<i class="pi pi-building text-primary-600 dark:text-primary-400"></i>
<h3 class="text-sm font-semibold text-surface-800 dark:text-surface-200">Manufacturer & Design</h3>
</div>
<i :class="['pi text-primary-500 dark:text-primary-400 transition-transform duration-300',
formState.expandedSections.manufacturer ? 'pi-chevron-up rotate-180' : 'pi-chevron-down']" />
</button>
<div v-if="formState.expandedSections.manufacturer" class="p-4 pt-0 space-y-4
bg-gradient-to-br from-surface-25 to-primary-25
dark:from-surface-875 dark:to-surface-850">
<div>
<label class="block text-sm font-semibold text-surface-700 dark:text-surface-300 mb-2">Manufacturer</label>
<MultiSelect
v-model="filters.manufacturer"
:options="filterOptions?.manufacturers || []"
optionLabel="label"
optionValue="value"
placeholder="Select manufacturers"
filter
class="w-full bg-surface-0 dark:bg-surface-800
border-primary-200 dark:border-primary-700
focus:border-primary-500 dark:focus:border-primary-400"
@change="updateFilter('manufacturer', $event.value)"
/>
</div>
<div>
<label class="block text-sm font-semibold text-surface-700 dark:text-surface-300 mb-2">Designer</label>
<MultiSelect
v-model="filters.designer"
:options="filterOptions?.designers || []"
optionLabel="label"
optionValue="value"
placeholder="Select designers"
filter
class="w-full bg-surface-0 dark:bg-surface-800
border-primary-200 dark:border-primary-700
focus:border-primary-500 dark:focus:border-primary-400"
@change="updateFilter('designer', $event.value)"
/>
</div>
</div>
</div>
<!-- Specifications Section -->
<div class="filter-section border-b border-primary-200 dark:border-primary-700">
<button
@click="toggleSection('specifications')"
class="w-full flex items-center justify-between p-4 text-left
bg-gradient-to-r from-surface-25 to-surface-50
dark:from-surface-875 dark:to-surface-850
hover:from-primary-50 hover:to-primary-100
dark:hover:from-primary-900/30 dark:hover:to-primary-800/30
transition-all duration-300"
>
<div class="flex items-center gap-3">
<i class="pi pi-cog text-primary-600 dark:text-primary-400"></i>
<h3 class="text-sm font-semibold text-surface-800 dark:text-surface-200">Specifications</h3>
</div>
<i :class="['pi text-primary-500 dark:text-primary-400 transition-transform duration-300',
formState.expandedSections.specifications ? 'pi-chevron-up rotate-180' : 'pi-chevron-down']" />
</button>
<div v-if="formState.expandedSections.specifications" class="p-4 pt-0 space-y-6
bg-gradient-to-br from-surface-25 to-primary-25
dark:from-surface-875 dark:to-surface-850">
<!-- Height Range -->
<div class="p-3 rounded-lg bg-gradient-to-br from-emerald-50 to-emerald-100/50
dark:from-emerald-900/20 dark:to-emerald-800/30
border border-emerald-200 dark:border-emerald-700">
<label class="block text-sm font-semibold text-emerald-700 dark:text-emerald-300 mb-3 flex items-center gap-2">
<i class="pi pi-arrow-up text-emerald-600 dark:text-emerald-400"></i>
Height (m)
</label>
<Slider
v-model="heightRange"
:min="0"
:max="200"
:step="1"
range
class="w-full"
@change="handleRangeUpdate('height', $event.value)"
/>
<div class="flex justify-between text-xs text-emerald-600 dark:text-emerald-400 mt-2 font-medium">
<span>{{ heightRange[0] }}m</span>
<span>{{ heightRange[1] }}m</span>
</div>
</div>
<!-- Speed Range -->
<div class="p-3 rounded-lg bg-gradient-to-br from-amber-50 to-amber-100/50
dark:from-amber-900/20 dark:to-amber-800/30
border border-amber-200 dark:border-amber-700">
<label class="block text-sm font-semibold text-amber-700 dark:text-amber-300 mb-3 flex items-center gap-2">
<i class="pi pi-bolt text-amber-600 dark:text-amber-400"></i>
Speed (km/h)
</label>
<Slider
v-model="speedRange"
:min="0"
:max="250"
:step="1"
range
class="w-full"
@change="handleRangeUpdate('speed', $event.value)"
/>
<div class="flex justify-between text-xs text-amber-600 dark:text-amber-400 mt-2 font-medium">
<span>{{ speedRange[0] }} km/h</span>
<span>{{ speedRange[1] }} km/h</span>
</div>
</div>
<!-- Length Range -->
<div class="p-3 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100/50
dark:from-blue-900/20 dark:to-blue-800/30
border border-blue-200 dark:border-blue-700">
<label class="block text-sm font-semibold text-blue-700 dark:text-blue-300 mb-3 flex items-center gap-2">
<i class="pi pi-arrows-h text-blue-600 dark:text-blue-400"></i>
Length (m)
</label>
<Slider
v-model="lengthRange"
:min="0"
:max="3000"
:step="10"
range
class="w-full"
@change="handleRangeUpdate('length', $event.value)"
/>
<div class="flex justify-between text-xs text-blue-600 dark:text-blue-400 mt-2 font-medium">
<span>{{ lengthRange[0] }}m</span>
<span>{{ lengthRange[1] }}m</span>
</div>
</div>
<!-- Capacity Range -->
<div class="p-3 rounded-lg bg-gradient-to-br from-purple-50 to-purple-100/50
dark:from-purple-900/20 dark:to-purple-800/30
border border-purple-200 dark:border-purple-700">
<label class="block text-sm font-semibold text-purple-700 dark:text-purple-300 mb-3 flex items-center gap-2">
<i class="pi pi-users text-purple-600 dark:text-purple-400"></i>
Capacity (people)
</label>
<Slider
v-model="capacityRange"
:min="1"
:max="50"
:step="1"
range
class="w-full"
@change="handleRangeUpdate('capacity', $event.value)"
/>
<div class="flex justify-between text-xs text-purple-600 dark:text-purple-400 mt-2 font-medium">
<span>{{ capacityRange[0] }} people</span>
<span>{{ capacityRange[1] }} people</span>
</div>
</div>
<!-- Duration Range -->
<div class="p-3 rounded-lg bg-gradient-to-br from-indigo-50 to-indigo-100/50
dark:from-indigo-900/20 dark:to-indigo-800/30
border border-indigo-200 dark:border-indigo-700">
<label class="block text-sm font-semibold text-indigo-700 dark:text-indigo-300 mb-3 flex items-center gap-2">
<i class="pi pi-clock text-indigo-600 dark:text-indigo-400"></i>
Duration (seconds)
</label>
<Slider
v-model="durationRange"
:min="30"
:max="600"
:step="5"
range
class="w-full"
@change="handleRangeUpdate('duration', $event.value)"
/>
<div class="flex justify-between text-xs text-indigo-600 dark:text-indigo-400 mt-2 font-medium">
<span>{{ durationRange[0] }}s</span>
<span>{{ durationRange[1] }}s</span>
</div>
</div>
<!-- Inversions Range -->
<div class="p-3 rounded-lg bg-gradient-to-br from-pink-50 to-pink-100/50
dark:from-pink-900/20 dark:to-pink-800/30
border border-pink-200 dark:border-pink-700">
<label class="block text-sm font-semibold text-pink-700 dark:text-pink-300 mb-3 flex items-center gap-2">
<i class="pi pi-replay text-pink-600 dark:text-pink-400"></i>
Inversions
</label>
<Slider
v-model="inversionsRange"
:min="0"
:max="20"
:step="1"
range
class="w-full"
@change="handleRangeUpdate('inversions', $event.value)"
/>
<div class="flex justify-between text-xs text-pink-600 dark:text-pink-400 mt-2 font-medium">
<span>{{ inversionsRange[0] }}</span>
<span>{{ inversionsRange[1] }}</span>
</div>
</div>
</div>
</div>
<!-- Dates Section -->
<div class="filter-section border-b border-primary-200 dark:border-primary-700">
<button
@click="toggleSection('dates')"
class="w-full flex items-center justify-between p-4 text-left
bg-gradient-to-r from-surface-25 to-surface-50
dark:from-surface-875 dark:to-surface-850
hover:from-primary-50 hover:to-primary-100
dark:hover:from-primary-900/30 dark:hover:to-primary-800/30
transition-all duration-300"
>
<div class="flex items-center gap-3">
<i class="pi pi-calendar text-primary-600 dark:text-primary-400"></i>
<h3 class="text-sm font-semibold text-surface-800 dark:text-surface-200">Opening & Closing Dates</h3>
</div>
<i :class="['pi text-primary-500 dark:text-primary-400 transition-transform duration-300',
formState.expandedSections.dates ? 'pi-chevron-up rotate-180' : 'pi-chevron-down']" />
</button>
<div v-if="formState.expandedSections.dates" class="p-4 pt-0 space-y-4
bg-gradient-to-br from-surface-25 to-primary-25
dark:from-surface-875 dark:to-surface-850">
<div>
<label class="block text-sm font-semibold text-surface-700 dark:text-surface-300 mb-2">Opening Date Range</label>
<div class="flex gap-2">
<Calendar
v-model="filters.opening_date_from"
placeholder="From"
dateFormat="yy-mm-dd"
class="flex-1 bg-surface-0 dark:bg-surface-800
border-primary-200 dark:border-primary-700
focus:border-primary-500 dark:focus:border-primary-400"
@date-select="updateFilter('opening_date_from', $event)"
/>
<Calendar
v-model="filters.opening_date_to"
placeholder="To"
dateFormat="yy-mm-dd"
class="flex-1 bg-surface-0 dark:bg-surface-800
border-primary-200 dark:border-primary-700
focus:border-primary-500 dark:focus:border-primary-400"
@date-select="updateFilter('opening_date_to', $event)"
/>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-surface-700 dark:text-surface-300 mb-2">Closing Date Range</label>
<div class="flex gap-2">
<Calendar
v-model="filters.closing_date_from"
placeholder="From"
dateFormat="yy-mm-dd"
class="flex-1 bg-surface-0 dark:bg-surface-800
border-primary-200 dark:border-primary-700
focus:border-primary-500 dark:focus:border-primary-400"
@date-select="updateFilter('closing_date_from', $event)"
/>
<Calendar
v-model="filters.closing_date_to"
placeholder="To"
dateFormat="yy-mm-dd"
class="flex-1 bg-surface-0 dark:bg-surface-800
border-primary-200 dark:border-primary-700
focus:border-primary-500 dark:focus:border-primary-400"
@date-select="updateFilter('closing_date_to', $event)"
/>
</div>
</div>
</div>
</div>
<!-- Location Section -->
<div class="filter-section border-b border-primary-200 dark:border-primary-700">
<button
@click="toggleSection('location')"
class="w-full flex items-center justify-between p-4 text-left
bg-gradient-to-r from-surface-25 to-surface-50
dark:from-surface-875 dark:to-surface-850
hover:from-primary-50 hover:to-primary-100
dark:hover:from-primary-900/30 dark:hover:to-primary-800/30
transition-all duration-300"
>
<div class="flex items-center gap-3">
<i class="pi pi-map-marker text-primary-600 dark:text-primary-400"></i>
<h3 class="text-sm font-semibold text-surface-800 dark:text-surface-200">Location</h3>
</div>
<i :class="['pi text-primary-500 dark:text-primary-400 transition-transform duration-300',
formState.expandedSections.location ? 'pi-chevron-up rotate-180' : 'pi-chevron-down']" />
</button>
<div v-if="formState.expandedSections.location" class="p-4 pt-0 space-y-4
bg-gradient-to-br from-surface-25 to-primary-25
dark:from-surface-875 dark:to-surface-850">
<div>
<label class="block text-sm font-semibold text-surface-700 dark:text-surface-300 mb-2">Park</label>
<MultiSelect
v-model="filters.park"
:options="filterOptions?.parks || []"
optionLabel="label"
optionValue="value"
placeholder="Select parks"
filter
class="w-full bg-surface-0 dark:bg-surface-800
border-primary-200 dark:border-primary-700
focus:border-primary-500 dark:focus:border-primary-400"
@change="updateFilter('park', $event.value)"
/>
</div>
<div>
<label class="block text-sm font-semibold text-surface-700 dark:text-surface-300 mb-2">Country</label>
<MultiSelect
v-model="filters.country"
:options="filterOptions?.countries || []"
optionLabel="label"
optionValue="value"
placeholder="Select countries"
class="w-full bg-surface-0 dark:bg-surface-800
border-primary-200 dark:border-primary-700
focus:border-primary-500 dark:focus:border-primary-400"
@change="updateFilter('country', $event.value)"
/>
</div>
<div>
<label class="block text-sm font-semibold text-surface-700 dark:text-surface-300 mb-2">Region</label>
<MultiSelect
v-model="filters.region"
:options="filterOptions?.regions || []"
optionLabel="label"
optionValue="value"
placeholder="Select regions"
class="w-full bg-surface-0 dark:bg-surface-800
border-primary-200 dark:border-primary-700
focus:border-primary-500 dark:focus:border-primary-400"
@change="updateFilter('region', $event.value)"
/>
</div>
</div>
</div>
<!-- Advanced Section -->
<div class="filter-section border-b border-primary-200 dark:border-primary-700">
<button
@click="toggleSection('advanced')"
class="w-full flex items-center justify-between p-4 text-left
bg-gradient-to-r from-surface-25 to-surface-50
dark:from-surface-875 dark:to-surface-850
hover:from-primary-50 hover:to-primary-100
dark:hover:from-primary-900/30 dark:hover:to-primary-800/30
transition-all duration-300"
>
<div class="flex items-center gap-3">
<i class="pi pi-sliders-h text-primary-600 dark:text-primary-400"></i>
<h3 class="text-sm font-semibold text-surface-800 dark:text-surface-200">Advanced Options</h3>
</div>
<i :class="['pi text-primary-500 dark:text-primary-400 transition-transform duration-300',
formState.expandedSections.advanced ? 'pi-chevron-up rotate-180' : 'pi-chevron-down']" />
</button>
<div v-if="formState.expandedSections.advanced" class="p-4 pt-0 space-y-4
bg-gradient-to-br from-surface-25 to-primary-25
dark:from-surface-875 dark:to-surface-850">
<div>
<label class="block text-sm font-semibold text-surface-700 dark:text-surface-300 mb-2">Sort By</label>
<Dropdown
v-model="filters.ordering"
:options="sortingOptions"
optionLabel="label"
optionValue="value"
placeholder="Select sorting"
class="w-full bg-surface-0 dark:bg-surface-800
border-primary-200 dark:border-primary-700
focus:border-primary-500 dark:focus:border-primary-400"
@change="updateFilter('ordering', $event.value)"
/>
</div>
<div>
<label class="block text-sm font-semibold text-surface-700 dark:text-surface-300 mb-2">Results Per Page</label>
<Dropdown
v-model="filters.page_size"
:options="pageSizeOptions"
optionLabel="label"
optionValue="value"
placeholder="Select page size"
class="w-full bg-surface-0 dark:bg-surface-800
border-primary-200 dark:border-primary-700
focus:border-primary-500 dark:focus:border-primary-400"
@change="updateFilter('page_size', $event.value)"
/>
</div>
</div>
</div>
</div>
<!-- Active Filters Display -->
<div
v-if="hasActiveFilters"
class="active-filters border-t border-primary-200 dark:border-primary-700 p-4
bg-gradient-to-br from-surface-0 to-primary-25
dark:from-surface-900 dark:to-surface-850"
>
<div class="flex items-center gap-2 mb-4">
<i class="pi pi-check-circle text-primary-600 dark:text-primary-400"></i>
<h3 class="text-sm font-semibold text-surface-800 dark:text-surface-200">
Active Filters ({{ activeFiltersCount }})
</h3>
</div>
<div class="flex flex-wrap gap-2">
<Badge
v-for="filter in activeFiltersList"
:key="`${filter.key}-${filter.value}`"
:value="filter.label"
severity="info"
class="cursor-pointer bg-gradient-to-r from-primary-500 to-primary-600
hover:from-primary-600 hover:to-primary-700
text-primary-50 font-medium px-3 py-1.5
border border-primary-300 dark:border-primary-600
shadow-md hover:shadow-lg transition-all duration-200"
@click="clearFilter(filter.key)"
>
<template #default>
{{ filter.label }}
<i class="pi pi-times ml-2 hover:text-red-200" />
</template>
</Badge>
</div>
</div>
<!-- Filter Presets -->
<div
v-if="savedPresets.length > 0"
class="filter-presets border-t border-primary-200 dark:border-primary-700 p-4
bg-gradient-to-br from-surface-0 to-primary-25
dark:from-surface-900 dark:to-surface-850"
>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<i class="pi pi-bookmark text-primary-600 dark:text-primary-400"></i>
<h3 class="text-sm font-semibold text-surface-800 dark:text-surface-200">Saved Presets</h3>
</div>
<Button
@click="showSavePresetDialog = true"
text
size="small"
class="text-xs text-primary-600 dark:text-primary-400
hover:bg-primary-100 dark:hover:bg-primary-800/50
font-medium"
>
<i class="pi pi-plus mr-1"></i>
Save Current
</Button>
</div>
<div class="space-y-3">
<Card
v-for="preset in savedPresets"
:key="preset.id"
class="p-3 bg-gradient-to-br from-surface-50 to-surface-100
dark:from-surface-800 dark:to-surface-750
border border-primary-200 dark:border-primary-700
hover:shadow-md transition-all duration-200"
>
<template #content>
<div class="flex items-center justify-between">
<div class="flex-1">
<h4 class="text-sm font-semibold text-surface-800 dark:text-surface-200">{{ preset.name }}</h4>
<p class="text-xs text-surface-600 dark:text-surface-400 flex items-center gap-1">
<i class="pi pi-filter text-primary-500"></i>
{{ preset.filterCount }} filters
</p>
</div>
<div class="flex gap-2">
<Button
@click="loadPreset(preset.id)"
:severity="currentPreset === preset.id ? 'primary' : 'secondary'"
size="small"
class="text-xs font-medium"
:class="currentPreset === preset.id
? 'bg-gradient-to-r from-primary-500 to-primary-600 text-primary-50'
: 'bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-200'"
>
<i class="pi pi-upload mr-1"></i>
Load
</Button>
<Button
@click="deletePreset(preset.id)"
severity="danger"
size="small"
text
class="text-red-600 dark:text-red-400
hover:bg-red-100 dark:hover:bg-red-900/30"
>
<i class="pi pi-trash" />
</Button>
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- Save Preset Dialog -->
<Dialog
:visible="showSavePresetDialog"
@update:visible="showSavePresetDialog = $event"
modal
:style="{ width: '25rem' }"
class="bg-surface-0 dark:bg-surface-900
border border-primary-200 dark:border-primary-700
shadow-2xl shadow-primary-500/20 dark:shadow-primary-900/40"
>
<template #header>
<div class="flex items-center gap-3 p-4
bg-gradient-to-r from-primary-500 via-primary-600 to-purple-600
dark:from-primary-600 dark:via-primary-700 dark:to-purple-700
text-primary-50">
<i class="pi pi-bookmark text-xl"></i>
<h3 class="text-lg font-bold">Save Filter Preset</h3>
</div>
</template>
<div class="p-6 space-y-4
bg-gradient-to-br from-surface-0 to-primary-25
dark:from-surface-900 dark:to-surface-850">
<div>
<label class="block text-sm font-semibold text-surface-700 dark:text-surface-300 mb-3 flex items-center gap-2">
<i class="pi pi-tag text-primary-600 dark:text-primary-400"></i>
Preset Name
</label>
<InputText
v-model="presetName"
placeholder="Enter preset name"
class="w-full bg-surface-0 dark:bg-surface-800
border-primary-200 dark:border-primary-700
focus:border-primary-500 dark:focus:border-primary-400
focus:ring-2 focus:ring-primary-200 dark:focus:ring-primary-800
text-surface-800 dark:text-surface-200
placeholder-surface-500 dark:placeholder-surface-400"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3 p-4
bg-gradient-to-r from-surface-50 to-surface-100
dark:from-surface-850 dark:to-surface-800
border-t border-primary-200 dark:border-primary-700">
<Button
@click="showSavePresetDialog = false"
text
severity="secondary"
class="bg-surface-200 dark:bg-surface-700
text-surface-700 dark:text-surface-200
hover:bg-surface-300 dark:hover:bg-surface-600
border border-surface-300 dark:border-surface-600"
>
<i class="pi pi-times mr-2"></i>
Cancel
</Button>
<Button
@click="handleSavePreset"
:disabled="!presetName.trim()"
class="bg-gradient-to-r from-primary-500 to-primary-600
hover:from-primary-600 hover:to-primary-700
disabled:from-surface-300 disabled:to-surface-400
disabled:text-surface-500
text-primary-50 font-semibold
shadow-lg hover:shadow-xl
transition-all duration-200"
>
<i class="pi pi-save mr-2"></i>
Save
</Button>
</div>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRideFilteringStore } from '@/stores/rideFiltering'
import { storeToRefs } from 'pinia'
// PrimeVue Components
import Badge from 'primevue/badge'
import Button from 'primevue/button'
import Card from 'primevue/card'
import Calendar from 'primevue/calendar'
import Dialog from 'primevue/dialog'
import Dropdown from 'primevue/dropdown'
import InputText from 'primevue/inputtext'
import MultiSelect from 'primevue/multiselect'
import Slider from 'primevue/slider'
// Store
const store = useRideFilteringStore()
const {
filters,
filterOptions,
formState,
hasActiveFilters,
activeFiltersCount,
activeFiltersList,
hasUnsavedChanges,
savedPresets,
currentPreset,
} = storeToRefs(store)
const {
updateFilter,
clearFilter,
clearAllFilters,
applyFilters,
toggleSection,
expandAllSections,
closeFilterForm,
savePreset,
loadPreset,
deletePreset,
} = store
// Local state
const showSavePresetDialog = ref(false)
const presetName = ref('')
const isMobile = ref(false)
// Range sliders reactive values
const heightRange = ref([0, 200])
const speedRange = ref([0, 250])
const lengthRange = ref([0, 3000])
const capacityRange = ref([1, 50])
const durationRange = ref([30, 600])
const inversionsRange = ref([0, 20])
// 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 handleRangeUpdate = (field: string, value: [number, number]) => {
updateFilter(`${field}_min`, value[0])
updateFilter(`${field}_max`, value[1])
}
const handleSavePreset = () => {
if (presetName.value.trim()) {
savePreset(presetName.value.trim())
showSavePresetDialog.value = false
presetName.value = ''
}
}
const checkMobile = () => {
isMobile.value = window.innerWidth < 1024
}
// Initialize range values from filters
const initializeRanges = () => {
if (filters.value.height_min !== undefined && filters.value.height_max !== undefined) {
heightRange.value = [filters.value.height_min, filters.value.height_max]
}
if (filters.value.speed_min !== undefined && filters.value.speed_max !== undefined) {
speedRange.value = [filters.value.speed_min, filters.value.speed_max]
}
if (filters.value.length_min !== undefined && filters.value.length_max !== undefined) {
lengthRange.value = [filters.value.length_min, filters.value.length_max]
}
if (filters.value.capacity_min !== undefined && filters.value.capacity_max !== undefined) {
capacityRange.value = [filters.value.capacity_min, filters.value.capacity_max]
}
if (filters.value.duration_min !== undefined && filters.value.duration_max !== undefined) {
durationRange.value = [filters.value.duration_min, filters.value.duration_max]
}
if (filters.value.inversions_min !== undefined && filters.value.inversions_max !== undefined) {
inversionsRange.value = [filters.value.inversions_min, filters.value.inversions_max]
}
}
// Lifecycle
onMounted(() => {
checkMobile()
initializeRanges()
window.addEventListener('resize', checkMobile)
})
</script>
<style scoped>
@reference "tailwindcss";
.ride-filter-sidebar {
@apply flex flex-col h-full bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700;
}
.filter-header {
@apply flex-shrink-0;
}
.filter-sections {
@apply flex-1 overflow-y-auto;
}
.active-filters {
@apply flex-shrink-0;
}
.filter-presets {
@apply flex-shrink-0;
}
/* Custom scrollbar for filter sections */
.filter-sections::-webkit-scrollbar {
@apply w-2;
}
.filter-sections::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-700;
}
.filter-sections::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
}
.filter-sections::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
</style>

View File

@@ -1,390 +0,0 @@
<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"
>
<i class="pi pi-times 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"
>
<i v-if="isLoading" class="pi pi-spinner pi-spin w-4 h-4" />
{{ editMode ? 'Update' : 'Save' }} Preset
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import type { FilterState, FilterPreset } from '@/types/filters'
interface Props {
isOpen: boolean
filters: FilterState
editMode?: boolean
existingPreset?: FilterPreset
allowGlobal?: boolean
existingNames?: string[]
}
const props = withDefaults(defineProps<Props>(), {
editMode: false,
allowGlobal: false,
existingNames: () => [],
})
defineEmits<{
close: []
save: [preset: Partial<FilterPreset>]
}>()
// Form data
const formData = ref({
name: '',
description: '',
scope: 'personal' as 'personal' | 'global',
isDefault: false,
})
// Form state
const errors = ref({
name: '',
})
const isLoading = ref(false)
// Computed
const isValid = computed(() => {
return formData.value.name.trim().length > 0 && !errors.value.name
})
const filterSummary = computed(() => {
const summary: Array<{ key: string; label: string; value: string }> = []
const filters = props.filters
if (filters.search?.trim()) {
summary.push({
key: 'search',
label: 'Search',
value: filters.search,
})
}
if (filters.categories?.length) {
summary.push({
key: 'categories',
label: 'Categories',
value: filters.categories.join(', '),
})
}
if (filters.manufacturers?.length) {
summary.push({
key: 'manufacturers',
label: 'Manufacturers',
value: filters.manufacturers.join(', '),
})
}
if (filters.designers?.length) {
summary.push({
key: 'designers',
label: 'Designers',
value: filters.designers.join(', '),
})
}
if (filters.parks?.length) {
summary.push({
key: 'parks',
label: 'Parks',
value: filters.parks.join(', '),
})
}
if (filters.status?.length) {
summary.push({
key: 'status',
label: 'Status',
value: filters.status.join(', '),
})
}
if (filters.opened?.start || filters.opened?.end) {
const start = filters.opened.start || 'Any'
const end = filters.opened.end || 'Any'
summary.push({
key: 'opened',
label: 'Opened',
value: `${start} to ${end}`,
})
}
if (filters.closed?.start || filters.closed?.end) {
const start = filters.closed.start || 'Any'
const end = filters.closed.end || 'Any'
summary.push({
key: 'closed',
label: 'Closed',
value: `${start} to ${end}`,
})
}
// Range filters
const ranges = [
{ key: 'heightRange', label: 'Height', data: filters.heightRange, unit: 'm' },
{ key: 'speedRange', label: 'Speed', data: filters.speedRange, unit: 'km/h' },
{ key: 'durationRange', label: 'Duration', data: filters.durationRange, unit: 'min' },
{ key: 'capacityRange', label: 'Capacity', data: filters.capacityRange, unit: '' },
]
ranges.forEach(({ key, label, data, unit }) => {
if (data?.min !== undefined || data?.max !== undefined) {
const min = data.min ?? 'Any'
const max = data.max ?? 'Any'
summary.push({
key,
label,
value: `${min} - ${max}${unit ? ' ' + unit : ''}`,
})
}
})
return summary
})
// Methods
const validateName = () => {
const name = formData.value.name.trim()
errors.value.name = ''
if (name.length === 0) {
errors.value.name = 'Preset name is required'
} else if (name.length < 2) {
errors.value.name = 'Preset name must be at least 2 characters'
} else if (name.length > 50) {
errors.value.name = 'Preset name must be 50 characters or less'
} else if (
props.existingNames.includes(name.toLowerCase()) &&
(!props.editMode || name.toLowerCase() !== props.existingPreset?.name.toLowerCase())
) {
errors.value.name = 'A preset with this name already exists'
}
}
const handleSave = async () => {
validateName()
if (!isValid.value) return
isLoading.value = true
try {
const preset: Partial<FilterPreset> = {
name: formData.value.name.trim(),
description: formData.value.description?.trim() || undefined,
filters: props.filters,
scope: formData.value.scope,
isDefault: formData.value.isDefault,
lastUsed: new Date().toISOString(),
}
if (props.editMode && props.existingPreset) {
preset.id = props.existingPreset.id
}
// Emit save event
await new Promise((resolve) => {
const emit = defineEmits<{
save: [preset: Partial<FilterPreset>]
}>()
emit('save', preset)
setTimeout(resolve, 100) // Small delay to simulate async operation
})
} finally {
isLoading.value = false
}
}
// Watchers
watch(
() => props.isOpen,
async (isOpen) => {
if (isOpen) {
if (props.editMode && props.existingPreset) {
formData.value = {
name: props.existingPreset.name,
description: props.existingPreset.description || '',
scope: props.existingPreset.scope || 'personal',
isDefault: props.existingPreset.isDefault || false,
}
} else {
formData.value = {
name: '',
description: '',
scope: 'personal',
isDefault: false,
}
}
errors.value.name = ''
// Focus the name input
await nextTick()
const nameInput = document.getElementById('preset-name')
if (nameInput) {
nameInput.focus()
}
}
},
{ immediate: true },
)
watch(
() => formData.value.name,
() => {
if (errors.value.name) {
validateName()
}
},
)
</script>
<style scoped>
/* Add any custom styles if needed */
</style>

View File

@@ -1,343 +0,0 @@
<template>
<div class="search-filter">
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="pi pi-search 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"
>
<i class="pi pi-times 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"
>
<i
:class="`pi ${getSuggestionIcon(suggestion.type)} 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"
>
<i :class="`pi ${getIconClass(filter.icon)} 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'
// 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: 'pi-chart-line',
park: 'pi-map-marker',
manufacturer: 'pi-building',
designer: 'pi-user',
category: 'pi-tag',
location: 'pi-globe',
}
return icons[type] || 'pi-search'
}
const getIconClass = (iconName: string): string => {
const iconMap: Record<string, string> = {
'play': 'pi-play',
'trending-up': 'pi-chart-line',
'droplet': 'pi-tint',
'users': 'pi-users',
'search': 'pi-search',
'filter': 'pi-filter',
'x': 'pi-times',
}
return iconMap[iconName] || 'pi-circle'
}
// 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>
@reference "tailwindcss";
.search-filter {
@apply relative;
}
.search-input {
@apply transition-colors duration-200;
}
.search-input:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
.suggestions-dropdown {
@apply border shadow-lg;
}
.suggestion-item {
@apply transition-colors duration-150;
}
.suggestion-item:first-child {
@apply rounded-t-md;
}
.suggestion-item:last-child {
@apply rounded-b-md;
}
/* Custom scrollbar for suggestions */
.suggestions-dropdown::-webkit-scrollbar {
@apply w-2;
}
.suggestions-dropdown::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-700;
}
.suggestions-dropdown::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
}
.suggestions-dropdown::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
</style>

View File

@@ -1,476 +0,0 @@
<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">
<i class="pi pi-search 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"
>
<i class="pi pi-times w-4 h-4" />
</button>
<i
v-else
class="pi pi-chevron-down 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"
>
<i class="pi pi-times 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'
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>
@reference "tailwindcss";
.search-input {
@apply transition-colors duration-200;
}
.search-input:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
.search-input:disabled {
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
}
.dropdown-menu {
@apply border shadow-lg;
}
.dropdown-item {
@apply transition-colors duration-150;
}
.dropdown-item:first-child {
@apply rounded-t-md;
}
.dropdown-item:last-child {
@apply rounded-b-md;
}
/* Custom scrollbar for dropdown */
.dropdown-menu::-webkit-scrollbar {
@apply w-2;
}
.dropdown-menu::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-700;
}
.dropdown-menu::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
}
.dropdown-menu::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
/* Highlight search terms */
:deep(mark) {
@apply bg-yellow-200 dark:bg-yellow-800 px-0;
}
</style>

View File

@@ -1,348 +0,0 @@
<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"
>
<i class="pi pi-times w-3 h-3" />
</button>
</span>
</span>
</span>
<i
class="pi pi-chevron-down 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'
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>
@reference "tailwindcss";
.select-input {
@apply transition-colors duration-200;
}
.select-input:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
.select-input:disabled {
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
}
.select-trigger {
@apply transition-colors duration-200;
}
.select-trigger:focus {
@apply ring-2 ring-blue-500 border-blue-500;
}
.select-trigger:disabled {
@apply bg-gray-100 dark:bg-gray-600 cursor-not-allowed opacity-50;
}
.dropdown-menu {
@apply border shadow-lg;
}
.dropdown-item {
@apply transition-colors duration-150;
}
.dropdown-item:first-child {
@apply rounded-t-md;
}
.dropdown-item:last-child {
@apply rounded-b-md;
}
/* Custom scrollbar for dropdown */
.dropdown-menu::-webkit-scrollbar {
@apply w-2;
}
.dropdown-menu::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-700;
}
.dropdown-menu::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
}
.dropdown-menu::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
</style>

View File

@@ -1,238 +0,0 @@
[data-lk-component='icon'] {
width: calc(1em * var(--lk-halfstep));
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
}
/* Regular families */
[data-lk-icon-font-class='display1'] {
--lineHeightInEms: calc(1em * var(--lk-quarterstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--sm: calc(var(--lineHeightInEms) * calc(1 / var(--lk-halfstep)));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.022em;
font-size: var(--display1-font-size);
font-weight: 400;
line-height: var(--lk-quarterstep);
}
[data-lk-icon-font-class='display2'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.022em;
font-size: var(--display2-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='title1'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.022em;
font-size: var(--title1-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='title2'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.02em;
font-size: var(--title2-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='title3'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.017em;
font-size: var(--title3-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='heading'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.014em;
font-size: var(--heading-font-size);
font-weight: 600;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='subheading'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.007em;
font-size: var(--subheading-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='body'] {
--lineHeightInEms: var(--title2-font-size);
--md: 1em;
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(1em * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.011em;
cursor: default;
font-size: 1em;
font-weight: 400;
line-height: var(--lk-wholestep);
}
[data-lk-icon-font-class='callout'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.009em;
font-size: var(--callout-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='label'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.004em;
font-size: var(--label-font-size);
font-weight: 600;
line-height: var(--lk-halfstep);
position: static;
}
[data-lk-icon-font-class='caption'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: -0.007em;
font-size: var(--caption-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
[data-lk-icon-font-class='capline'] {
--lineHeightInEms: calc(1em * var(--lk-halfstep));
--md: var(--lineHeightInEms);
--sm: calc(var(--lineHeightInEms) * var(--lk-wholestep-dec));
--xs: calc(var(--lineHeightInEms) * var(--lk-halfstep-dec));
--2xs: calc(var(--lineHeightInEms) * var(--lk-quarterstep-dec));
--lg: calc(var(--lineHeightInEms) * var(--lk-wholestep));
--xl: calc(var(--lk-size-lg) * var(--lk-wholestep));
--2xl: calc(var(--lk-size-xl) * var(--lk-wholestep));
letter-spacing: 0.0618em;
text-transform: uppercase;
font-size: var(--capline-font-size);
font-weight: 400;
line-height: var(--lk-halfstep);
}
/* Ignore the width and aspect ratio rules when inside an icon-button component */
[data-lk-component='icon-button'] [data-lk-component='icon'] {
width: unset;
aspect-ratio: unset;
}
[data-lk-icon-offset='true'] {
margin-top: calc(-1 * calc(1em * var(--lk-quarterstep-dec)));
}

View File

@@ -1,38 +0,0 @@
import { DynamicIcon } from 'lucide-react/dynamic'
import type { IconName } from 'lucide-react/dynamic'
import '@/components/icon/icon.css'
export interface LkIconProps extends React.HTMLAttributes<HTMLElement> {
name?: IconName
fontClass?: Exclude<LkFontClass, `${string}-bold` | `${string}-mono`>
color?: LkColor | 'currentColor'
display?: 'block' | 'inline-block' | 'inline'
strokeWidth?: number
opticShift?: boolean //if true, pulls icon slightly upward
}
export default function Icon({
name = 'roller-coaster',
fontClass,
color = 'onsurface',
strokeWidth = 2,
opticShift = false,
...restProps
}: LkIconProps) {
return (
<div
data-lk-component="icon"
data-lk-icon-offset={opticShift}
{...restProps}
data-lk-icon-font-class={fontClass}
>
<DynamicIcon
name={name}
width="1em"
height="1em"
color={`var(--lk-${color})`}
strokeWidth={strokeWidth}
/>
</div>
)
}

View File

@@ -1,7 +0,0 @@
<template>
<svg viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
</template>

View File

@@ -1,20 +0,0 @@
<template>
<svg viewBox="0 0 24 24" class="w-5 h-5">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
</template>

View File

@@ -1,259 +0,0 @@
<template>
<nav :class="navClasses">
<div :class="containerClasses">
<div class="flex items-center justify-between h-full">
<!-- Left section -->
<div class="flex items-center space-x-4">
<!-- Logo/Brand -->
<slot name="brand">
<router-link
to="/"
class="flex items-center space-x-2 text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<slot name="logo">
<div
class="w-8 h-8 bg-blue-600 dark:bg-blue-500 rounded-lg flex items-center justify-center"
>
<span class="text-white font-bold text-sm">T</span>
</div>
</slot>
<span class="font-semibold text-lg hidden sm:block">ThrillWiki</span>
</router-link>
</slot>
<!-- Navigation Links -->
<div class="hidden md:flex items-center space-x-1">
<slot name="nav-links">
<NavLink to="/parks" :active="$route.path.startsWith('/parks')"> Parks </NavLink>
<NavLink to="/rides" :active="$route.path.startsWith('/rides')"> Rides </NavLink>
<NavLink to="/search" :active="$route.path === '/search'"> Search </NavLink>
</slot>
</div>
</div>
<!-- Center section (optional) -->
<div class="flex-1 max-w-lg mx-4 hidden lg:block">
<slot name="center" />
</div>
<!-- Right section -->
<div class="flex items-center space-x-2">
<slot name="actions">
<!-- Search for mobile -->
<Button
v-if="showMobileSearch"
variant="ghost"
size="sm"
icon-only
class="md:hidden"
@click="$emit('toggle-search')"
aria-label="Toggle search"
>
<SearchIcon class="w-5 h-5" />
</Button>
<!-- Theme controller -->
<ThemeController v-if="showThemeToggle" variant="button" size="sm" />
<!-- User menu or auth buttons -->
<slot name="user-menu">
<Button variant="outline" size="sm" class="hidden sm:flex"> Sign In </Button>
</slot>
</slot>
<!-- Mobile menu button -->
<Button
v-if="showMobileMenu"
variant="ghost"
size="sm"
icon-only
class="md:hidden"
@click="toggleMobileMenu"
:aria-expanded="mobileMenuOpen"
aria-label="Toggle navigation menu"
>
<MenuIcon v-if="!mobileMenuOpen" class="w-5 h-5" />
<XIcon v-else class="w-5 h-5" />
</Button>
</div>
</div>
<!-- Mobile menu -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="mobileMenuOpen"
class="md:hidden border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800"
>
<div class="px-2 pt-2 pb-3 space-y-1">
<slot name="mobile-nav">
<MobileNavLink to="/parks" @click="closeMobileMenu"> Parks </MobileNavLink>
<MobileNavLink to="/rides" @click="closeMobileMenu"> Rides </MobileNavLink>
<MobileNavLink to="/search" @click="closeMobileMenu"> Search </MobileNavLink>
</slot>
</div>
<!-- Mobile user section -->
<div class="pt-4 pb-3 border-t border-gray-200 dark:border-gray-700">
<slot name="mobile-user">
<div class="px-2">
<Button variant="outline" size="sm" block @click="closeMobileMenu">
Sign In
</Button>
</div>
</slot>
</div>
</div>
</Transition>
</div>
</nav>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import Button from '../ui/Button.vue'
import ThemeController from './ThemeController.vue'
// Icons (using simple SVG icons)
const SearchIcon = {
template: `
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
`,
}
const MenuIcon = {
template: `
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
`,
}
const XIcon = {
template: `
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
`,
}
// NavLink component for desktop navigation
const NavLink = {
props: {
to: String,
active: Boolean,
},
template: `
<router-link
:to="to"
:class="[
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
active
? 'bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-gray-100 dark:hover:bg-gray-700'
]"
>
<slot />
</router-link>
`,
}
// MobileNavLink component for mobile navigation
const MobileNavLink = {
props: {
to: String,
},
template: `
<router-link
:to="to"
class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
@click="$emit('click')"
>
<slot />
</router-link>
`,
emits: ['click'],
}
// Props
interface NavbarProps {
sticky?: boolean
shadow?: boolean
height?: 'compact' | 'default' | 'comfortable'
showMobileSearch?: boolean
showThemeToggle?: boolean
showMobileMenu?: boolean
}
const props = withDefaults(defineProps<NavbarProps>(), {
sticky: true,
shadow: true,
height: 'default',
showMobileSearch: true,
showThemeToggle: true,
showMobileMenu: true,
})
// Emits
const emit = defineEmits<{
'toggle-search': []
'mobile-menu-open': []
'mobile-menu-close': []
}>()
// State
const mobileMenuOpen = ref(false)
// Computed classes
const navClasses = computed(() => {
let classes =
'bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700 transition-colors duration-200'
if (props.sticky) {
classes += ' sticky top-0 z-50'
}
if (props.shadow) {
classes += ' shadow-sm'
}
return classes
})
const containerClasses = computed(() => {
let classes = 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8'
if (props.height === 'compact') {
classes += ' h-14'
} else if (props.height === 'comfortable') {
classes += ' h-20'
} else {
classes += ' h-16'
}
return classes
})
// Methods
const toggleMobileMenu = () => {
mobileMenuOpen.value = !mobileMenuOpen.value
if (mobileMenuOpen.value) {
emit('mobile-menu-open')
} else {
emit('mobile-menu-close')
}
}
const closeMobileMenu = () => {
mobileMenuOpen.value = false
emit('mobile-menu-close')
}
</script>

View File

@@ -1,172 +0,0 @@
<template>
<div :class="containerClasses">
<!-- Button variant -->
<PrimeButton
v-if="variant === 'button'"
:variant="'ghost'"
:size="size === 'sm' ? 'small' : size === 'lg' ? 'large' : undefined"
:icon-start="currentTheme === 'dark' ? 'pi pi-sun' : 'pi pi-moon'"
:icon-only="!showText"
@click="toggleTheme"
:aria-label="currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
>
<span v-if="showText">
{{ currentTheme === 'dark' ? 'Light' : 'Dark' }}
</span>
</PrimeButton>
<!-- Dropdown variant -->
<div v-else-if="variant === 'dropdown'" class="relative">
<PrimeButton
:variant="'ghost'"
:size="size === 'sm' ? 'small' : size === 'lg' ? 'large' : undefined"
:icon-start="getThemeIcon()"
:icon-end="showDropdown ? 'pi pi-chevron-down' : undefined"
@click="dropdownOpen = !dropdownOpen"
:aria-expanded="dropdownOpen"
aria-haspopup="true"
>
<span v-if="showText">
{{ getThemeLabel() }}
</span>
</PrimeButton>
<!-- Dropdown menu -->
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-if="dropdownOpen"
:class="dropdownMenuClasses"
role="menu"
aria-orientation="vertical"
@click.stop
>
<!-- Light mode option -->
<button
type="button"
:class="dropdownItemClasses(currentTheme === 'light')"
@click="setTheme('light')"
role="menuitem"
>
<i class="pi pi-sun mr-2" />
Light
</button>
<!-- Dark mode option -->
<button
type="button"
:class="dropdownItemClasses(currentTheme === 'dark')"
@click="setTheme('dark')"
role="menuitem"
>
<i class="pi pi-moon mr-2" />
Dark
</button>
<!-- System mode option -->
<button
type="button"
:class="dropdownItemClasses(currentTheme === 'system')"
@click="setTheme('system')"
role="menuitem"
>
<i class="pi pi-desktop mr-2" />
System
</button>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onClickOutside } from '@vueuse/core'
import { useTheme } from '@/composables/useTheme'
import PrimeButton from '@/components/primevue/PrimeButton.vue'
interface PrimeThemeControllerProps {
variant?: 'button' | 'dropdown'
size?: 'sm' | 'md' | 'lg'
showText?: boolean
showDropdown?: boolean
position?: 'fixed' | 'relative'
}
const props = withDefaults(defineProps<PrimeThemeControllerProps>(), {
variant: 'button',
size: 'md',
showText: false,
showDropdown: true,
position: 'relative',
})
// Use theme composable
const { currentTheme, setTheme, toggleTheme } = useTheme()
// Dropdown state
const dropdownOpen = ref(false)
const dropdownRef = ref()
// Close dropdown when clicking outside
onClickOutside(dropdownRef, () => {
dropdownOpen.value = false
})
// Computed classes
const containerClasses = computed(() => {
return props.position === 'fixed' ? 'fixed top-4 right-4 z-50' : 'relative'
})
const dropdownMenuClasses = computed(() => {
return [
'absolute right-0 mt-2 w-48 rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5',
'dark:bg-gray-800 dark:ring-gray-700',
'focus:outline-none z-50 py-1',
].join(' ')
})
// Helper methods
const getThemeIcon = () => {
if (currentTheme.value === 'dark') return 'pi pi-moon'
if (currentTheme.value === 'light') return 'pi pi-sun'
return 'pi pi-desktop'
}
const getThemeLabel = () => {
if (currentTheme.value === 'dark') return 'Dark'
if (currentTheme.value === 'light') return 'Light'
return 'System'
}
// Dropdown item classes
const dropdownItemClasses = (isActive: boolean) => {
const baseClasses =
'flex w-full items-center px-4 py-2 text-left text-sm transition-colors rounded-md mx-1'
const activeClasses = isActive
? 'bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
return `${baseClasses} ${activeClasses}`
}
</script>
<style scoped>
/* Additional styling for dropdown transitions */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

View File

@@ -1,363 +0,0 @@
<template>
<div :class="containerClasses">
<!-- Button variant -->
<button
v-if="variant === 'button'"
type="button"
:class="buttonClasses"
@click="toggleTheme"
:aria-label="currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
>
<!-- Sun icon (light mode) -->
<svg
v-if="currentTheme === 'dark'"
:class="iconClasses"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<!-- Moon icon (dark mode) -->
<svg v-else :class="iconClasses" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
<span v-if="showText" :class="textClasses">
{{ currentTheme === 'dark' ? 'Light' : 'Dark' }}
</span>
</button>
<!-- Dropdown variant -->
<div v-else-if="variant === 'dropdown'" class="relative">
<button
type="button"
:class="dropdownButtonClasses"
@click="dropdownOpen = !dropdownOpen"
@blur="handleBlur"
:aria-expanded="dropdownOpen"
aria-haspopup="true"
>
<!-- Current theme icon -->
<svg
v-if="currentTheme === 'dark'"
:class="iconClasses"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
<svg
v-else-if="currentTheme === 'light'"
:class="iconClasses"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<svg v-else :class="iconClasses" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<span v-if="showText" :class="textClasses">
{{ currentTheme === 'dark' ? 'Dark' : currentTheme === 'light' ? 'Light' : 'Auto' }}
</span>
<!-- Dropdown arrow -->
<svg
v-if="showDropdown"
class="ml-2 h-4 w-4 transition-transform"
:class="{ 'rotate-180': dropdownOpen }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<!-- Dropdown menu -->
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-if="dropdownOpen"
:class="dropdownMenuClasses"
role="menu"
aria-orientation="vertical"
>
<!-- Light mode option -->
<button
type="button"
:class="dropdownItemClasses(currentTheme === 'light')"
@click="setTheme('light')"
role="menuitem"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
Light
</button>
<!-- Dark mode option -->
<button
type="button"
:class="dropdownItemClasses(currentTheme === 'dark')"
@click="setTheme('dark')"
role="menuitem"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
Dark
</button>
<!-- System mode option -->
<button
type="button"
:class="dropdownItemClasses(currentTheme === 'system')"
@click="setTheme('system')"
role="menuitem"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
System
</button>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
interface ThemeControllerProps {
variant?: 'button' | 'dropdown'
size?: 'sm' | 'md' | 'lg'
showText?: boolean
showDropdown?: boolean
position?: 'fixed' | 'relative'
}
const props = withDefaults(defineProps<ThemeControllerProps>(), {
variant: 'button',
size: 'md',
showText: false,
showDropdown: true,
position: 'relative',
})
// State
const currentTheme = ref<'light' | 'dark' | 'system'>('system')
const dropdownOpen = ref(false)
// Computed classes
const containerClasses = computed(() => {
return props.position === 'fixed' ? 'fixed top-4 right-4 z-50' : 'relative'
})
const sizeClasses = computed(() => {
const sizes = {
sm: {
button: 'h-8 px-2',
icon: 'h-4 w-4',
text: 'text-sm',
},
md: {
button: 'h-10 px-3',
icon: 'h-5 w-5',
text: 'text-sm',
},
lg: {
button: 'h-12 px-4',
icon: 'h-6 w-6',
text: 'text-base',
},
}
return sizes[props.size]
})
const buttonClasses = computed(() => {
return [
'inline-flex items-center justify-center rounded-md border border-gray-300 bg-white text-gray-700 transition-colors',
'hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700',
sizeClasses.value.button,
].join(' ')
})
const dropdownButtonClasses = computed(() => {
return [
'inline-flex items-center justify-center rounded-md border border-gray-300 bg-white text-gray-700 transition-colors',
'hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700',
sizeClasses.value.button,
].join(' ')
})
const dropdownMenuClasses = computed(() => {
return [
'absolute right-0 mt-2 w-48 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5',
'dark:bg-gray-800 dark:ring-gray-700',
'focus:outline-none z-50',
].join(' ')
})
const iconClasses = computed(() => {
let classes = sizeClasses.value.icon
if (props.showText) classes += ' mr-2'
return classes
})
const textClasses = computed(() => {
return `${sizeClasses.value.text} font-medium`
})
// Dropdown item classes
const dropdownItemClasses = (isActive: boolean) => {
const baseClasses = 'flex w-full items-center px-4 py-2 text-left text-sm transition-colors'
const activeClasses = isActive
? 'bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
return `${baseClasses} ${activeClasses}`
}
// Theme management
const applyTheme = (theme: 'light' | 'dark') => {
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
const getSystemTheme = (): 'light' | 'dark' => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const setTheme = (theme: 'light' | 'dark' | 'system') => {
currentTheme.value = theme
localStorage.setItem('theme', theme)
if (theme === 'system') {
applyTheme(getSystemTheme())
} else {
applyTheme(theme)
}
dropdownOpen.value = false
}
const toggleTheme = () => {
if (currentTheme.value === 'light') {
setTheme('dark')
} else {
setTheme('light')
}
}
const handleBlur = (event: FocusEvent) => {
// Close dropdown if focus moves outside
const relatedTarget = event.relatedTarget as Element
if (!relatedTarget || !relatedTarget.closest('[role="menu"]')) {
dropdownOpen.value = false
}
}
// Initialize theme
onMounted(() => {
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null
if (savedTheme) {
currentTheme.value = savedTheme
} else {
currentTheme.value = 'system'
}
if (currentTheme.value === 'system') {
applyTheme(getSystemTheme())
} else {
applyTheme(currentTheme.value)
}
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleSystemThemeChange = () => {
if (currentTheme.value === 'system') {
applyTheme(getSystemTheme())
}
}
mediaQuery.addEventListener('change', handleSystemThemeChange)
// Cleanup
return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange)
}
})
// Watch for theme changes
watch(currentTheme, (newTheme) => {
if (newTheme === 'system') {
applyTheme(getSystemTheme())
} else {
applyTheme(newTheme)
}
})
</script>

View File

@@ -1,106 +0,0 @@
<template>
<Badge :class="badgeClasses" :severity="severity" :size="size" :value="value">
<slot />
</Badge>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Badge from 'primevue/badge'
interface PrimeBadgeProps {
variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info'
size?: 'sm' | 'md' | 'lg'
value?: string | number
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full'
}
const props = withDefaults(defineProps<PrimeBadgeProps>(), {
variant: 'default',
size: 'md',
rounded: 'md',
})
// Map variants to PrimeVue severity
const severity = computed(() => {
const severityMap = {
default: 'info',
secondary: 'secondary',
destructive: 'danger',
outline: 'contrast',
success: 'success',
warning: 'warn',
info: 'info',
}
return severityMap[props.variant] as
| 'success'
| 'info'
| 'warn'
| 'danger'
| 'secondary'
| 'contrast'
| undefined
})
// Additional classes for styling
const badgeClasses = computed(() => {
const classes = []
// Size classes
const sizeClasses = {
sm: 'text-xs px-2 py-1',
md: 'text-sm px-2.5 py-1',
lg: 'text-base px-3 py-1.5',
}
classes.push(sizeClasses[props.size])
// Border radius
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
}
classes.push(roundedClasses[props.rounded])
// Variant-specific styling
if (props.variant === 'outline') {
classes.push('border border-gray-300 dark:border-gray-600 bg-transparent')
}
return classes.join(' ')
})
</script>
<style scoped>
/* Custom badge styling to match our theme */
:deep(.p-badge) {
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Default variant with gradient */
:deep(.p-badge.p-badge-info) {
background: linear-gradient(135deg, #2563eb, #7c3aed);
color: white;
border: none;
}
/* Dark mode adjustments */
.dark :deep(.p-badge.p-badge-info) {
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
}
/* Outline variant */
:deep(.p-badge.border) {
background: transparent !important;
color: #374151;
}
.dark :deep(.p-badge.border) {
color: #d1d5db;
}
</style>

View File

@@ -1,161 +0,0 @@
<template>
<Button
:class="buttonClasses"
:disabled="disabled || loading"
:type="type"
:severity="severity"
:size="size"
:outlined="variant === 'outline'"
:text="variant === 'ghost' || variant === 'link'"
:link="variant === 'link'"
:loading="loading"
:loadingIcon="loadingIcon"
:icon="iconStart"
:iconPos="iconStart ? 'left' : iconEnd ? 'right' : undefined"
@click="handleClick"
>
<!-- Button content -->
<template v-if="!iconOnly">
<slot />
</template>
<!-- Icon for icon-only buttons -->
<template v-if="iconOnly && (iconStart || iconEnd)" #icon>
<i :class="iconStart || iconEnd" />
</template>
</Button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Button from 'primevue/button'
interface PrimeButtonProps {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive'
size?: 'small' | 'large' | undefined
disabled?: boolean
loading?: boolean
block?: boolean
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full'
iconStart?: string
iconEnd?: string
iconOnly?: boolean
href?: string
to?: string | object
target?: string
type?: 'button' | 'submit' | 'reset'
loadingIcon?: string
}
const props = withDefaults(defineProps<PrimeButtonProps>(), {
variant: 'primary',
disabled: false,
loading: false,
block: false,
rounded: 'md',
iconOnly: false,
type: 'button',
loadingIcon: 'pi pi-spin pi-spinner',
})
const emit = defineEmits<{
click: [event: Event]
}>()
// Map variants to PrimeVue severity
const severity = computed(() => {
const severityMap = {
primary: 'primary',
secondary: 'secondary',
outline: 'primary',
ghost: 'secondary',
link: 'secondary',
destructive: 'danger',
}
return severityMap[props.variant] as
| 'primary'
| 'secondary'
| 'success'
| 'info'
| 'warn'
| 'help'
| 'danger'
| 'contrast'
| undefined
})
// Additional classes for styling
const buttonClasses = computed(() => {
const classes = []
// Block width
if (props.block) {
classes.push('w-full')
}
// Border radius
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
}
classes.push(roundedClasses[props.rounded])
// Gradient for primary buttons
if (props.variant === 'primary' && !props.loading && !props.disabled) {
classes.push(
'bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700',
)
classes.push('border-0 text-white')
}
// Icon-only styling
if (props.iconOnly) {
classes.push('aspect-square')
}
// Link variant styling
if (props.variant === 'link') {
classes.push('underline-offset-4 hover:underline')
}
return classes.join(' ')
})
// Handle click events
const handleClick = (event: Event) => {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
</script>
<style scoped>
/* Custom gradient override for primary buttons */
.p-button.bg-gradient-to-r {
background-image: linear-gradient(to right, #2563eb, #7c3aed) !important;
}
.p-button.bg-gradient-to-r:hover {
background-image: linear-gradient(to right, #1d4ed8, #6d28d9) !important;
}
.p-button.bg-gradient-to-r:active {
background-image: linear-gradient(to right, #1e40af, #5b21b6) !important;
}
/* Dark mode gradient adjustments */
.dark .p-button.bg-gradient-to-r {
background-image: linear-gradient(to right, #3b82f6, #8b5cf6) !important;
}
.dark .p-button.bg-gradient-to-r:hover {
background-image: linear-gradient(to right, #2563eb, #7c3aed) !important;
}
.dark .p-button.bg-gradient-to-r:active {
background-image: linear-gradient(to right, #1d4ed8, #6d28d9) !important;
}
</style>

View File

@@ -1,188 +0,0 @@
<template>
<Card :class="cardClasses">
<!-- Header slot -->
<template v-if="title || $slots.header" #header>
<div :class="headerClasses">
<slot name="header">
<h3 v-if="title" :class="titleClasses">
{{ title }}
</h3>
</slot>
</div>
</template>
<!-- Content slot -->
<template #content>
<div :class="contentClasses">
<slot />
</div>
</template>
<!-- Footer slot -->
<template v-if="$slots.footer" #footer>
<div :class="footerClasses">
<slot name="footer" />
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Card from 'primevue/card'
interface PrimeCardProps {
variant?: 'default' | 'outline' | 'ghost' | 'elevated' | 'featured'
size?: 'sm' | 'md' | 'lg' | 'xl'
title?: string
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
hover?: boolean
interactive?: boolean
bordered?: boolean
}
const props = withDefaults(defineProps<PrimeCardProps>(), {
variant: 'default',
size: 'md',
padding: 'md',
rounded: 'lg',
shadow: 'sm',
hover: false,
interactive: false,
bordered: true,
})
// Card classes
const cardClasses = computed(() => {
const classes = []
// Variant styling
if (props.variant === 'featured') {
classes.push('border-blue-200 dark:border-blue-800')
classes.push('bg-gradient-to-br from-blue-50 to-white dark:from-blue-950 dark:to-gray-800')
} else if (props.variant === 'outline') {
classes.push('border-2 border-gray-300 dark:border-gray-600')
} else if (props.variant === 'ghost') {
classes.push('border-0 bg-transparent dark:bg-transparent shadow-none')
} else if (props.variant === 'elevated') {
classes.push('border-0 shadow-lg')
}
// Shadow
if (props.variant !== 'ghost') {
const shadowClasses = {
none: '',
sm: 'shadow-sm hover:shadow-md',
md: 'shadow-md hover:shadow-lg',
lg: 'shadow-lg hover:shadow-xl',
xl: 'shadow-xl hover:shadow-2xl',
'2xl': 'shadow-2xl',
}
classes.push(shadowClasses[props.shadow])
}
// Rounded corners
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
'2xl': 'rounded-2xl',
}
classes.push(roundedClasses[props.rounded])
// Interactive effects
if (props.interactive) {
classes.push('cursor-pointer hover:scale-[1.01] active:scale-[0.99] hover:-translate-y-0.5')
classes.push('transition-all duration-200')
} else if (props.hover) {
classes.push('hover:shadow-lg transition-shadow duration-200')
}
// Block width
classes.push('w-full')
return classes.join(' ')
})
// Header classes
const headerClasses = computed(() => {
const classes = ['border-b border-gray-200 dark:border-gray-700']
const paddingClasses = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
xl: 'p-8',
}
classes.push(paddingClasses[props.padding])
return classes.join(' ')
})
// Content classes
const contentClasses = computed(() => {
const paddingClasses = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
xl: 'p-8',
}
return paddingClasses[props.padding]
})
// Footer classes
const footerClasses = computed(() => {
const classes = ['border-t border-gray-200 dark:border-gray-700']
const paddingClasses = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
xl: 'p-8',
}
classes.push(paddingClasses[props.padding])
return classes.join(' ')
})
// Title classes
const titleClasses = computed(() => {
const sizeClasses = {
sm: 'text-lg font-semibold',
md: 'text-xl font-semibold',
lg: 'text-2xl font-semibold',
xl: 'text-3xl font-bold',
}
return `${sizeClasses[props.size]} text-gray-900 dark:text-gray-100 tracking-tight`
})
</script>
<style scoped>
/* Override PrimeVue Card default styles to match our design */
:deep(.p-card) {
background: transparent;
border: none;
box-shadow: none;
}
:deep(.p-card-header) {
padding: 0;
border: none;
}
:deep(.p-card-content) {
padding: 0;
}
:deep(.p-card-footer) {
padding: 0;
border: none;
}
</style>

View File

@@ -1,225 +0,0 @@
<template>
<Dialog
:visible="visible"
:modal="modal"
:header="header"
:closable="closable"
:draggable="draggable"
:resizable="resizable"
:maximizable="maximizable"
:class="dialogClasses"
:style="dialogStyle"
@update:visible="handleVisibleChange"
@hide="handleHide"
@show="handleShow"
>
<!-- Header slot -->
<template v-if="$slots.header" #header>
<slot name="header" />
</template>
<!-- Content -->
<div :class="contentClasses">
<slot />
</div>
<!-- Footer slot -->
<template v-if="$slots.footer" #footer>
<div :class="footerClasses">
<slot name="footer" />
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Dialog from 'primevue/dialog'
interface PrimeDialogProps {
visible?: boolean
header?: string
modal?: boolean
closable?: boolean
draggable?: boolean
resizable?: boolean
maximizable?: boolean
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
position?:
| 'center'
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right'
}
const props = withDefaults(defineProps<PrimeDialogProps>(), {
visible: false,
modal: true,
closable: true,
draggable: false,
resizable: false,
maximizable: false,
size: 'md',
position: 'center',
})
const emit = defineEmits<{
'update:visible': [value: boolean]
hide: []
show: []
}>()
// Dialog classes
const dialogClasses = computed(() => {
const classes = []
// Size classes
const sizeClasses = {
sm: 'w-full max-w-sm',
md: 'w-full max-w-md',
lg: 'w-full max-w-lg',
xl: 'w-full max-w-xl',
full: 'w-full max-w-full h-full',
}
classes.push(sizeClasses[props.size])
// Position classes
const positionClasses = {
center: '',
top: 'mt-8',
bottom: 'mb-8',
left: 'ml-8',
right: 'mr-8',
'top-left': 'mt-8 ml-8',
'top-right': 'mt-8 mr-8',
'bottom-left': 'mb-8 ml-8',
'bottom-right': 'mb-8 mr-8',
}
classes.push(positionClasses[props.position])
return classes.join(' ')
})
// Dialog style
const dialogStyle = computed(() => {
const styles: Record<string, string> = {}
if (props.size === 'full') {
styles.width = '100vw'
styles.height = '100vh'
styles.maxWidth = '100vw'
styles.maxHeight = '100vh'
}
return styles
})
// Content classes
const contentClasses = computed(() => {
const classes = ['text-gray-900 dark:text-gray-100']
// Add padding based on size
const paddingClasses = {
sm: 'p-4',
md: 'p-6',
lg: 'p-6',
xl: 'p-8',
full: 'p-8',
}
classes.push(paddingClasses[props.size])
return classes.join(' ')
})
// Footer classes
const footerClasses = computed(() => {
return 'flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700'
})
// Event handlers
const handleVisibleChange = (value: boolean) => {
emit('update:visible', value)
}
const handleHide = () => {
emit('hide')
}
const handleShow = () => {
emit('show')
}
</script>
<style scoped>
/* Override PrimeVue Dialog styles */
:deep(.p-dialog) {
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid #e5e7eb;
}
.dark :deep(.p-dialog) {
background-color: #1f2937;
border-color: #374151;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
:deep(.p-dialog-header) {
background: transparent;
border-bottom: 1px solid #e5e7eb;
border-radius: 0.75rem 0.75rem 0 0;
padding: 1.5rem 1.5rem 1rem 1.5rem;
}
.dark :deep(.p-dialog-header) {
border-bottom-color: #374151;
}
:deep(.p-dialog-title) {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.dark :deep(.p-dialog-title) {
color: #f9fafb;
}
:deep(.p-dialog-content) {
padding: 0;
background: transparent;
}
:deep(.p-dialog-footer) {
background: transparent;
border-top: none;
padding: 0 1.5rem 1.5rem 1.5rem;
}
:deep(.p-dialog-header-close) {
width: 2rem;
height: 2rem;
border-radius: 0.375rem;
color: #6b7280;
transition: all 0.2s;
}
:deep(.p-dialog-header-close:hover) {
background-color: #f3f4f6;
color: #374151;
}
.dark :deep(.p-dialog-header-close) {
color: #9ca3af;
}
.dark :deep(.p-dialog-header-close:hover) {
background-color: #374151;
color: #d1d5db;
}
</style>

View File

@@ -1,251 +0,0 @@
<template>
<div :class="wrapperClasses">
<!-- Label -->
<label v-if="label" :for="inputId" :class="labelClasses">
{{ label }}
<span v-if="required" class="text-red-500 ml-1">*</span>
</label>
<!-- Input wrapper for icons -->
<div class="relative">
<!-- Start icon -->
<div
v-if="iconStart"
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
>
<i :class="[iconStart, 'text-gray-400 dark:text-gray-500']" />
</div>
<!-- Input field -->
<InputText
:id="inputId"
:class="inputClasses"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:invalid="invalid"
:modelValue="modelValue"
@update:modelValue="handleInput"
@blur="handleBlur"
@focus="handleFocus"
@keyup.enter="handleEnter"
/>
<!-- End icon -->
<div
v-if="iconEnd"
class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"
>
<i :class="[iconEnd, 'text-gray-400 dark:text-gray-500']" />
</div>
<!-- Clear button -->
<button
v-if="clearable && modelValue && !disabled && !readonly"
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center"
@click="clearInput"
>
<i
class="pi pi-times text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
/>
</button>
</div>
<!-- Helper text -->
<div v-if="helperText || errorMessage" :class="helperClasses">
{{ errorMessage || helperText }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import InputText from 'primevue/inputtext'
interface PrimeInputProps {
modelValue?: string | number
label?: string
placeholder?: string
helperText?: string
errorMessage?: string
disabled?: boolean
readonly?: boolean
required?: boolean
invalid?: boolean
clearable?: boolean
size?: 'sm' | 'md' | 'lg'
variant?: 'default' | 'filled' | 'outlined'
iconStart?: string
iconEnd?: string
block?: boolean
}
const props = withDefaults(defineProps<PrimeInputProps>(), {
size: 'md',
variant: 'outlined',
block: true,
disabled: false,
readonly: false,
required: false,
invalid: false,
clearable: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string | number]
blur: [event: FocusEvent]
focus: [event: FocusEvent]
enter: [event: KeyboardEvent]
}>()
// Generate unique ID for accessibility
const inputId = ref(`input-${Math.random().toString(36).substr(2, 9)}`)
// Wrapper classes
const wrapperClasses = computed(() => {
const classes = []
if (props.block) {
classes.push('w-full')
}
return classes.join(' ')
})
// Label classes
const labelClasses = computed(() => {
const classes = ['block text-sm font-medium mb-2']
if (props.invalid || props.errorMessage) {
classes.push('text-red-600 dark:text-red-400')
} else {
classes.push('text-gray-700 dark:text-gray-300')
}
return classes.join(' ')
})
// Input classes
const inputClasses = computed(() => {
const classes = []
// Size classes
const sizeClasses = {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-3 text-sm',
lg: 'h-12 px-4 text-base',
}
classes.push(sizeClasses[props.size])
// Icon padding adjustments
if (props.iconStart) {
classes.push('pl-10')
}
if (props.iconEnd || props.clearable) {
classes.push('pr-10')
}
// Block width
if (props.block) {
classes.push('w-full')
}
// Border radius
classes.push('rounded-md')
// Error state
if (props.invalid || props.errorMessage) {
classes.push('border-red-500 focus:border-red-500 focus:ring-red-500')
}
// Variant styling
if (props.variant === 'filled') {
classes.push('bg-gray-50 dark:bg-gray-800 border-transparent')
}
return classes.join(' ')
})
// Helper text classes
const helperClasses = computed(() => {
const classes = ['mt-2 text-sm']
if (props.errorMessage) {
classes.push('text-red-600 dark:text-red-400')
} else {
classes.push('text-gray-500 dark:text-gray-400')
}
return classes.join(' ')
})
// Event handlers
const handleInput = (value: string | number) => {
emit('update:modelValue', value)
}
const handleBlur = (event: FocusEvent) => {
emit('blur', event)
}
const handleFocus = (event: FocusEvent) => {
emit('focus', event)
}
const handleEnter = (event: KeyboardEvent) => {
emit('enter', event)
}
const clearInput = () => {
emit('update:modelValue', '')
}
</script>
<style scoped>
/* Override PrimeVue InputText styles */
:deep(.p-inputtext) {
border: 1px solid #d1d5db;
transition: all 0.2s ease-in-out;
}
:deep(.p-inputtext:enabled:hover) {
border-color: #9ca3af;
}
:deep(.p-inputtext:enabled:focus) {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Dark mode styles */
.dark :deep(.p-inputtext) {
background-color: #374151;
border-color: #4b5563;
color: #f3f4f6;
}
.dark :deep(.p-inputtext:enabled:hover) {
border-color: #6b7280;
}
.dark :deep(.p-inputtext:enabled:focus) {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.dark :deep(.p-inputtext::placeholder) {
color: #9ca3af;
}
/* Invalid state */
:deep(.p-inputtext.p-invalid) {
border-color: #ef4444;
}
:deep(.p-inputtext.p-invalid:enabled:focus) {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
</style>

View File

@@ -1,211 +0,0 @@
<template>
<div :class="wrapperClasses">
<!-- Label -->
<div v-if="label || showValue" :class="labelWrapperClasses">
<span v-if="label" :class="labelClasses">{{ label }}</span>
<span v-if="showValue" :class="valueClasses">{{ displayValue }}</span>
</div>
<!-- Progress bar -->
<ProgressBar :value="value" :class="progressClasses" :showValue="false">
<!-- Custom content slot -->
<template v-if="$slots.default" #default>
<slot />
</template>
</ProgressBar>
<!-- Helper text -->
<div v-if="helperText" :class="helperClasses">
{{ helperText }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ProgressBar from 'primevue/progressbar'
interface PrimeProgressProps {
value?: number
label?: string
helperText?: string
showValue?: boolean
size?: 'sm' | 'md' | 'lg'
variant?: 'default' | 'gradient' | 'striped'
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
animated?: boolean
indeterminate?: boolean
}
const props = withDefaults(defineProps<PrimeProgressProps>(), {
value: 0,
size: 'md',
variant: 'gradient',
color: 'primary',
showValue: true,
animated: false,
indeterminate: false,
})
// Wrapper classes
const wrapperClasses = computed(() => {
return 'w-full'
})
// Label wrapper classes
const labelWrapperClasses = computed(() => {
return 'flex justify-between items-center mb-2'
})
// Label classes
const labelClasses = computed(() => {
return 'text-sm font-medium text-gray-700 dark:text-gray-300'
})
// Value classes
const valueClasses = computed(() => {
return 'text-sm font-medium text-gray-500 dark:text-gray-400'
})
// Progress classes
const progressClasses = computed(() => {
const classes = []
// Size classes
const sizeClasses = {
sm: 'h-2',
md: 'h-3',
lg: 'h-4',
}
classes.push(sizeClasses[props.size])
// Variant classes
if (props.variant === 'gradient') {
classes.push('progress-gradient')
} else if (props.variant === 'striped') {
classes.push('progress-striped')
}
// Color classes
const colorClasses = {
primary: 'progress-primary',
success: 'progress-success',
warning: 'progress-warning',
danger: 'progress-danger',
info: 'progress-info',
}
classes.push(colorClasses[props.color])
// Animation
if (props.animated) {
classes.push('progress-animated')
}
return classes.join(' ')
})
// Helper text classes
const helperClasses = computed(() => {
return 'mt-2 text-sm text-gray-500 dark:text-gray-400'
})
// Display value
const displayValue = computed(() => {
if (props.indeterminate) {
return 'Loading...'
}
return `${Math.round(props.value)}%`
})
</script>
<style scoped>
/* Override PrimeVue ProgressBar styles */
:deep(.p-progressbar) {
border-radius: 9999px;
background-color: #e5e7eb;
overflow: hidden;
}
.dark :deep(.p-progressbar) {
background-color: #374151;
}
:deep(.p-progressbar-value) {
border-radius: 9999px;
transition: width 0.3s ease-in-out;
}
/* Gradient variants */
:deep(.progress-gradient.progress-primary .p-progressbar-value) {
background: linear-gradient(90deg, #2563eb 0%, #7c3aed 100%);
}
.dark :deep(.progress-gradient.progress-primary .p-progressbar-value) {
background: linear-gradient(90deg, #3b82f6 0%, #8b5cf6 100%);
}
:deep(.progress-gradient.progress-success .p-progressbar-value) {
background: linear-gradient(90deg, #059669 0%, #10b981 100%);
}
:deep(.progress-gradient.progress-warning .p-progressbar-value) {
background: linear-gradient(90deg, #d97706 0%, #f59e0b 100%);
}
:deep(.progress-gradient.progress-danger .p-progressbar-value) {
background: linear-gradient(90deg, #dc2626 0%, #ef4444 100%);
}
:deep(.progress-gradient.progress-info .p-progressbar-value) {
background: linear-gradient(90deg, #0891b2 0%, #06b6d4 100%);
}
/* Striped variant */
:deep(.progress-striped .p-progressbar-value) {
background-image: linear-gradient(
45deg,
rgba(255, 255, 255, 0.15) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.15) 50%,
rgba(255, 255, 255, 0.15) 75%,
transparent 75%,
transparent
);
background-size: 1rem 1rem;
}
/* Animated stripes */
:deep(.progress-animated.progress-striped .p-progressbar-value) {
animation: progress-bar-stripes 1s linear infinite;
}
@keyframes progress-bar-stripes {
0% {
background-position: 1rem 0;
}
100% {
background-position: 0 0;
}
}
/* Indeterminate animation */
:deep(.p-progressbar.progress-indeterminate .p-progressbar-value) {
animation: progress-indeterminate 2s ease-in-out infinite;
}
@keyframes progress-indeterminate {
0% {
width: 0%;
margin-left: 0%;
}
50% {
width: 50%;
margin-left: 25%;
}
100% {
width: 0%;
margin-left: 100%;
}
}
</style>

View File

@@ -1,296 +0,0 @@
<template>
<div :class="wrapperClasses">
<!-- Label -->
<label v-if="label" :for="selectId" :class="labelClasses">
{{ label }}
<span v-if="required" class="text-red-500 ml-1">*</span>
</label>
<!-- Select dropdown -->
<Dropdown
:id="selectId"
:class="selectClasses"
:modelValue="modelValue"
:options="options"
:optionLabel="optionLabel"
:optionValue="optionValue"
:optionGroupLabel="optionGroupLabel"
:optionGroupChildren="optionGroupChildren"
:placeholder="placeholder"
:disabled="disabled"
:invalid="invalid"
:filter="searchable"
:filterPlaceholder="searchPlaceholder"
:showClear="clearable"
:loading="loading"
:emptyMessage="emptyMessage"
:emptyFilterMessage="emptyFilterMessage"
@update:modelValue="handleChange"
@change="handleChange"
@filter="handleFilter"
@show="handleShow"
@hide="handleHide"
>
<!-- Custom option template -->
<template v-if="$slots.option" #option="slotProps">
<slot name="option" v-bind="slotProps" />
</template>
<!-- Custom value template -->
<template v-if="$slots.value" #value="slotProps">
<slot name="value" v-bind="slotProps" />
</template>
<!-- Custom header template -->
<template v-if="$slots.header" #header>
<slot name="header" />
</template>
<!-- Custom footer template -->
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
</Dropdown>
<!-- Helper text -->
<div v-if="helperText || errorMessage" :class="helperClasses">
{{ errorMessage || helperText }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import Dropdown from 'primevue/dropdown'
interface PrimeSelectProps {
modelValue?: any
options?: any[]
optionLabel?: string
optionValue?: string
optionGroupLabel?: string
optionGroupChildren?: string
label?: string
placeholder?: string
helperText?: string
errorMessage?: string
disabled?: boolean
required?: boolean
invalid?: boolean
searchable?: boolean
clearable?: boolean
loading?: boolean
block?: boolean
size?: 'sm' | 'md' | 'lg'
variant?: 'default' | 'filled' | 'outlined'
emptyMessage?: string
emptyFilterMessage?: string
searchPlaceholder?: string
}
const props = withDefaults(defineProps<PrimeSelectProps>(), {
size: 'md',
variant: 'outlined',
block: true,
disabled: false,
required: false,
invalid: false,
searchable: false,
clearable: false,
loading: false,
emptyMessage: 'No results found',
emptyFilterMessage: 'No results found',
searchPlaceholder: 'Search...',
})
const emit = defineEmits<{
'update:modelValue': [value: any]
change: [event: { originalEvent: Event; value: any }]
filter: [event: { originalEvent: Event; value: string }]
show: []
hide: []
}>()
// Generate unique ID for accessibility
const selectId = ref(`select-${Math.random().toString(36).substr(2, 9)}`)
// Wrapper classes
const wrapperClasses = computed(() => {
const classes = []
if (props.block) {
classes.push('w-full')
}
return classes.join(' ')
})
// Label classes
const labelClasses = computed(() => {
const classes = ['block text-sm font-medium mb-2']
if (props.invalid || props.errorMessage) {
classes.push('text-red-600 dark:text-red-400')
} else {
classes.push('text-gray-700 dark:text-gray-300')
}
return classes.join(' ')
})
// Select classes
const selectClasses = computed(() => {
const classes = []
// Size classes
const sizeClasses = {
sm: 'h-8 text-sm',
md: 'h-10 text-sm',
lg: 'h-12 text-base',
}
classes.push(sizeClasses[props.size])
// Block width
if (props.block) {
classes.push('w-full')
}
// Border radius
classes.push('rounded-md')
// Error state
if (props.invalid || props.errorMessage) {
classes.push('border-red-500')
}
return classes.join(' ')
})
// Helper text classes
const helperClasses = computed(() => {
const classes = ['mt-2 text-sm']
if (props.errorMessage) {
classes.push('text-red-600 dark:text-red-400')
} else {
classes.push('text-gray-500 dark:text-gray-400')
}
return classes.join(' ')
})
// Event handlers
const handleChange = (value: any) => {
emit('update:modelValue', value)
emit('change', { originalEvent: new Event('change'), value })
}
const handleFilter = (event: { originalEvent: Event; value: string }) => {
emit('filter', event)
}
const handleShow = () => {
emit('show')
}
const handleHide = () => {
emit('hide')
}
</script>
<style scoped>
/* Override PrimeVue Dropdown styles */
:deep(.p-dropdown) {
border: 1px solid #d1d5db;
transition: all 0.2s ease-in-out;
background-color: #ffffff;
}
:deep(.p-dropdown:not(.p-disabled):hover) {
border-color: #9ca3af;
}
:deep(.p-dropdown:not(.p-disabled).p-focus) {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Dark mode styles */
.dark :deep(.p-dropdown) {
background-color: #374151;
border-color: #4b5563;
color: #f3f4f6;
}
.dark :deep(.p-dropdown:not(.p-disabled):hover) {
border-color: #6b7280;
}
.dark :deep(.p-dropdown:not(.p-disabled).p-focus) {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.dark :deep(.p-dropdown-label) {
color: #f3f4f6;
}
.dark :deep(.p-dropdown-trigger) {
color: #9ca3af;
}
/* Dropdown panel */
:deep(.p-dropdown-panel) {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.dark :deep(.p-dropdown-panel) {
background-color: #374151;
border-color: #4b5563;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.3),
0 4px 6px -2px rgba(0, 0, 0, 0.2);
}
:deep(.p-dropdown-item) {
padding: 0.75rem 1rem;
transition: all 0.2s;
}
:deep(.p-dropdown-item:hover) {
background-color: #f3f4f6;
}
.dark :deep(.p-dropdown-item) {
color: #f3f4f6;
}
.dark :deep(.p-dropdown-item:hover) {
background-color: #4b5563;
}
:deep(.p-dropdown-item.p-highlight) {
background-color: #dbeafe;
color: #1e40af;
}
.dark :deep(.p-dropdown-item.p-highlight) {
background-color: #1e40af;
color: #dbeafe;
}
/* Invalid state */
:deep(.p-dropdown.p-invalid) {
border-color: #ef4444;
}
:deep(.p-dropdown.p-invalid:not(.p-disabled).p-focus) {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
</style>

View File

@@ -1,129 +0,0 @@
<template>
<Skeleton
:class="skeletonClasses"
:width="width"
:height="height"
:borderRadius="borderRadius"
:animation="animation"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Skeleton from 'primevue/skeleton'
interface PrimeSkeletonProps {
variant?: 'text' | 'circular' | 'rectangular' | 'rounded'
size?: 'sm' | 'md' | 'lg' | 'xl'
width?: string
height?: string
animation?: 'pulse' | 'wave' | 'none'
lines?: number
}
const props = withDefaults(defineProps<PrimeSkeletonProps>(), {
variant: 'rectangular',
size: 'md',
animation: 'pulse',
lines: 1,
})
// Skeleton classes
const skeletonClasses = computed(() => {
const classes = []
// Variant classes
if (props.variant === 'text') {
classes.push('skeleton-text')
} else if (props.variant === 'circular') {
classes.push('skeleton-circular')
} else if (props.variant === 'rounded') {
classes.push('skeleton-rounded')
}
// Size classes for predefined variants
if (!props.width && !props.height) {
const sizeClasses = {
sm: props.variant === 'circular' ? 'w-8 h-8' : props.variant === 'text' ? 'h-4' : 'w-16 h-16',
md:
props.variant === 'circular' ? 'w-12 h-12' : props.variant === 'text' ? 'h-5' : 'w-24 h-24',
lg:
props.variant === 'circular' ? 'w-16 h-16' : props.variant === 'text' ? 'h-6' : 'w-32 h-32',
xl:
props.variant === 'circular' ? 'w-20 h-20' : props.variant === 'text' ? 'h-8' : 'w-40 h-40',
}
classes.push(sizeClasses[props.size])
}
return classes.join(' ')
})
// Border radius
const borderRadius = computed(() => {
if (props.variant === 'circular') return '50%'
if (props.variant === 'rounded') return '0.5rem'
if (props.variant === 'text') return '0.25rem'
return '0.375rem'
})
</script>
<style scoped>
/* Override PrimeVue Skeleton styles */
:deep(.p-skeleton) {
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
background-size: 200% 100%;
}
.dark :deep(.p-skeleton) {
background: linear-gradient(90deg, #374151 25%, #4b5563 50%, #374151 75%);
background-size: 200% 100%;
}
/* Animation variants */
:deep(.p-skeleton[data-animation='pulse']) {
animation: skeleton-pulse 2s ease-in-out infinite;
}
:deep(.p-skeleton[data-animation='wave']) {
animation: skeleton-wave 1.5s ease-in-out infinite;
}
:deep(.p-skeleton[data-animation='none']) {
animation: none;
}
/* Text variant specific styling */
.skeleton-text :deep(.p-skeleton) {
border-radius: 0.25rem;
}
/* Circular variant specific styling */
.skeleton-circular :deep(.p-skeleton) {
border-radius: 50%;
}
/* Rounded variant specific styling */
.skeleton-rounded :deep(.p-skeleton) {
border-radius: 0.5rem;
}
/* Animations */
@keyframes skeleton-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes skeleton-wave {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
</style>

View File

@@ -1,46 +0,0 @@
// PrimeVue component exports for easy importing
export { default as PrimeButton } from './PrimeButton.vue'
export { default as PrimeCard } from './PrimeCard.vue'
export { default as PrimeInput } from './PrimeInput.vue'
export { default as PrimeBadge } from './PrimeBadge.vue'
export { default as PrimeDialog } from './PrimeDialog.vue'
export { default as PrimeSelect } from './PrimeSelect.vue'
export { default as PrimeProgress } from './PrimeProgress.vue'
export { default as PrimeSkeleton } from './PrimeSkeleton.vue'
// Re-export commonly used PrimeVue components with consistent naming
export { default as Toast } from 'primevue/toast'
export { default as Dialog } from 'primevue/dialog'
export { default as ConfirmDialog } from 'primevue/confirmdialog'
export { default as Dropdown } from 'primevue/dropdown'
export { default as MultiSelect } from 'primevue/multiselect'
export { default as Calendar } from 'primevue/calendar'
export { default as Slider } from 'primevue/slider'
export { default as ProgressBar } from 'primevue/progressbar'
export { default as Badge } from 'primevue/badge'
export { default as Chip } from 'primevue/chip'
export { default as Avatar } from 'primevue/avatar'
export { default as AvatarGroup } from 'primevue/avatargroup'
export { default as Skeleton } from 'primevue/skeleton'
export { default as DataTable } from 'primevue/datatable'
export { default as Column } from 'primevue/column'
export { default as Paginator } from 'primevue/paginator'
export { default as Menu } from 'primevue/menu'
export { default as MenuBar } from 'primevue/menubar'
export { default as ContextMenu } from 'primevue/contextmenu'
export { default as Breadcrumb } from 'primevue/breadcrumb'
export { default as Steps } from 'primevue/steps'
export { default as TabView } from 'primevue/tabview'
export { default as TabPanel } from 'primevue/tabpanel'
export { default as Accordion } from 'primevue/accordion'
export { default as AccordionTab } from 'primevue/accordiontab'
export { default as Fieldset } from 'primevue/fieldset'
export { default as Panel } from 'primevue/panel'
export { default as Splitter } from 'primevue/splitter'
export { default as SplitterPanel } from 'primevue/splitterpanel'
export { default as Divider } from 'primevue/divider'
export { default as ScrollPanel } from 'primevue/scrollpanel'
export { default as Toolbar } from 'primevue/toolbar'
export { default as Sidebar } from 'primevue/sidebar'
export { default as OverlayPanel } from 'primevue/overlaypanel'
export { default as Tooltip } from 'primevue/tooltip'

View File

@@ -1,345 +0,0 @@
<template>
<Card
class="group relative overflow-hidden cursor-pointer
bg-gradient-to-br from-surface-0 via-surface-50 to-primary-50
dark:from-surface-900 dark:via-surface-950 dark:to-surface-800
border border-primary-200 dark:border-primary-800
shadow-lg shadow-primary-100/20 dark:shadow-primary-900/20
hover:shadow-xl hover:shadow-primary-200/30 dark:hover:shadow-primary-800/30
hover:scale-[1.02] transition-all duration-300 ease-out
backdrop-blur-sm"
@click="$emit('click')"
>
<!-- Ride Image -->
<div class="aspect-w-16 aspect-h-9 relative overflow-hidden">
<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-500 ease-out"
/>
<div
v-else
class="w-full h-48 flex items-center justify-center
bg-gradient-to-br from-primary-500 via-primary-600 to-purple-600
dark:from-primary-600 dark:via-primary-700 dark:to-purple-700"
>
<i class="pi pi-camera text-6xl text-primary-50 opacity-70 drop-shadow-lg"></i>
</div>
<!-- Image Overlay Gradient -->
<div class="absolute inset-0 bg-gradient-to-t from-surface-900/20 via-transparent to-transparent"></div>
</div>
<!-- Status Badge -->
<div class="absolute top-4 right-4 z-10">
<Badge
:severity="getStatusSeverity(ride.status)"
class="shadow-lg backdrop-blur-sm font-medium text-xs px-3 py-1.5
bg-surface-0/90 dark:bg-surface-900/90
border border-primary-200 dark:border-primary-700"
>
{{ getStatusDisplay(ride.status) }}
</Badge>
</div>
<template #content>
<div class="p-6 space-y-4">
<!-- Ride Name and Category -->
<div class="space-y-2">
<h3 class="text-xl font-bold line-clamp-1
bg-gradient-to-r from-primary-700 via-primary-600 to-purple-600
dark:from-primary-400 dark:via-primary-300 dark:to-purple-400
bg-clip-text text-transparent">
{{ ride.name }}
</h3>
<p class="text-sm font-semibold text-primary-600 dark:text-primary-400 uppercase tracking-wide">
{{ ride.category_display || ride.category }}
</p>
</div>
<!-- Park Name -->
<div class="flex items-center space-x-2 p-2 rounded-lg
bg-gradient-to-r from-surface-50 to-primary-50/50
dark:from-surface-800 dark:to-primary-900/20
border border-primary-100 dark:border-primary-800">
<i class="pi pi-map-marker text-primary-500 dark:text-primary-400"></i>
<span class="text-sm font-medium text-surface-700 dark:text-surface-300">
{{ ride.park_name }}
</span>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-2 gap-3">
<!-- Height -->
<div v-if="ride.height_ft" class="flex items-center space-x-2 p-2 rounded-lg
bg-gradient-to-br from-emerald-50 to-emerald-100/50
dark:from-emerald-900/20 dark:to-emerald-800/30
border border-emerald-200 dark:border-emerald-700">
<i class="pi pi-arrow-up text-emerald-600 dark:text-emerald-400"></i>
<span class="text-sm font-medium text-emerald-700 dark:text-emerald-300">{{ ride.height_ft }}ft</span>
</div>
<!-- Speed -->
<div v-if="ride.speed_mph" class="flex items-center space-x-2 p-2 rounded-lg
bg-gradient-to-br from-amber-50 to-amber-100/50
dark:from-amber-900/20 dark:to-amber-800/30
border border-amber-200 dark:border-amber-700">
<i class="pi pi-bolt text-amber-600 dark:text-amber-400"></i>
<span class="text-sm font-medium text-amber-700 dark:text-amber-300">{{ ride.speed_mph }} mph</span>
</div>
<!-- Duration -->
<div v-if="ride.ride_duration_seconds" class="flex items-center space-x-2 p-2 rounded-lg
bg-gradient-to-br from-blue-50 to-blue-100/50
dark:from-blue-900/20 dark:to-blue-800/30
border border-blue-200 dark:border-blue-700">
<i class="pi pi-clock text-blue-600 dark:text-blue-400"></i>
<span class="text-sm font-medium text-blue-700 dark:text-blue-300">
{{ formatDuration(ride.ride_duration_seconds) }}
</span>
</div>
<!-- Capacity -->
<div v-if="ride.capacity_per_hour" class="flex items-center space-x-2 p-2 rounded-lg
bg-gradient-to-br from-purple-50 to-purple-100/50
dark:from-purple-900/20 dark:to-purple-800/30
border border-purple-200 dark:border-purple-700">
<i class="pi pi-users text-purple-600 dark:text-purple-400"></i>
<span class="text-sm font-medium text-purple-700 dark:text-purple-300">
{{ formatCapacity(ride.capacity_per_hour) }}/hr
</span>
</div>
</div>
<!-- Rating and Opening Date -->
<div class="flex items-center justify-between p-3 rounded-lg
bg-gradient-to-r from-surface-50 via-surface-25 to-primary-50
dark:from-surface-800 dark:via-surface-850 dark:to-primary-900/30
border border-primary-100 dark:border-primary-800">
<!-- Rating -->
<div
v-if="ride.average_rating && typeof ride.average_rating === 'number'"
class="flex items-center space-x-1"
>
<i class="pi pi-star-fill text-yellow-500 text-base drop-shadow-sm"></i>
<span class="text-sm font-bold text-surface-800 dark:text-surface-200">
{{ ride.average_rating.toFixed(1) }}
</span>
<span class="text-xs text-surface-600 dark:text-surface-400">
({{ ride.review_count || 0 }})
</span>
</div>
<!-- Opening Date -->
<div v-if="ride.opening_date" class="text-xs font-medium text-surface-600 dark:text-surface-400">
Opened {{ formatYear(ride.opening_date) }}
</div>
</div>
<!-- Description Preview -->
<div v-if="ride.description" class="p-3 rounded-lg
bg-gradient-to-br from-surface-25 to-surface-50
dark:from-surface-875 dark:to-surface-850
border border-surface-200 dark:border-surface-700">
<p class="text-sm text-surface-700 dark:text-surface-300 line-clamp-2 leading-relaxed">
{{ ride.description }}
</p>
</div>
<!-- Manufacturer/Designer -->
<div
v-if="ride.manufacturer_name || ride.designer_name"
class="space-y-2"
>
<div class="flex flex-wrap gap-2">
<Badge
v-if="ride.manufacturer_name"
class="bg-gradient-to-r from-slate-100 to-slate-200
dark:from-slate-800 dark:to-slate-700
text-slate-700 dark:text-slate-300
border border-slate-300 dark:border-slate-600
text-xs font-medium px-2 py-1"
>
<i class="pi pi-building mr-1"></i>
{{ ride.manufacturer_name }}
</Badge>
<Badge
v-if="ride.designer_name"
class="bg-gradient-to-r from-indigo-100 to-indigo-200
dark:from-indigo-800 dark:to-indigo-700
text-indigo-700 dark:text-indigo-300
border border-indigo-300 dark:border-indigo-600
text-xs font-medium px-2 py-1"
>
<i class="pi pi-user mr-1"></i>
{{ ride.designer_name }}
</Badge>
</div>
</div>
<!-- Special Features -->
<div v-if="hasSpecialFeatures" class="flex flex-wrap gap-2">
<Badge
v-if="ride.inversions && ride.inversions > 0"
class="bg-gradient-to-r from-purple-100 to-purple-200
dark:from-purple-800 dark:to-purple-700
text-purple-800 dark:text-purple-200
border border-purple-300 dark:border-purple-600
text-xs font-medium px-2 py-1"
>
{{ ride.inversions }} inversion{{ ride.inversions !== 1 ? 's' : '' }}
</Badge>
<Badge
v-if="ride.launch_type"
class="bg-gradient-to-r from-orange-100 to-orange-200
dark:from-orange-800 dark:to-orange-700
text-orange-800 dark:text-orange-200
border border-orange-300 dark:border-orange-600
text-xs font-medium px-2 py-1"
>
{{ ride.launch_type }} launch
</Badge>
<Badge
v-if="ride.track_material"
class="bg-gradient-to-r from-green-100 to-green-200
dark:from-green-800 dark:to-green-700
text-green-800 dark:text-green-200
border border-green-300 dark:border-green-600
text-xs font-medium px-2 py-1"
>
{{ ride.track_material }}
</Badge>
</div>
</div>
</template>
<!-- Enhanced Hover Overlay -->
<div
class="absolute inset-0 bg-gradient-to-br from-primary-500/5 via-transparent to-purple-500/5
opacity-0 group-hover:opacity-100 transition-all duration-300 pointer-events-none
backdrop-blur-[0.5px]"
></div>
</Card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Ride } from '@/types'
import Card from 'primevue/card'
import Badge from 'primevue/badge'
// Using PrimeIcons instead of lucide-vue-next
// 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 getStatusSeverity = (status: string) => {
switch (status?.toLowerCase()) {
case 'operating':
return 'success'
case 'closed':
case 'permanently_closed':
return 'danger'
case 'under_construction':
return 'info'
case 'seasonal':
return 'warning'
case 'maintenance':
return 'secondary'
default:
return 'secondary'
}
}
const getStatusDisplay = (status: string) => {
switch (status?.toLowerCase()) {
case 'operating':
return 'Operating'
case 'closed':
return 'Closed'
case 'permanently_closed':
return 'Permanently Closed'
case 'under_construction':
return 'Under Construction'
case 'seasonal':
return 'Seasonal'
case 'maintenance':
return 'Maintenance'
default:
return status || 'Unknown'
}
}
const formatDuration = (seconds: number) => {
if (seconds < 60) {
return `${seconds}s`
}
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
if (remainingSeconds === 0) {
return `${minutes}m`
}
return `${minutes}m ${remainingSeconds}s`
}
const formatCapacity = (capacity: number) => {
if (capacity >= 1000) {
return `${(capacity / 1000).toFixed(1)}k`
}
return capacity.toString()
}
const formatYear = (dateString: string) => {
return new Date(dateString).getFullYear()
}
</script>
<style scoped>
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.aspect-w-16 {
position: relative;
padding-bottom: calc(9 / 16 * 100%);
}
.aspect-w-16 > * {
position: absolute;
height: 100%;
width: 100%;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
</style>

View File

@@ -1,486 +0,0 @@
<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">
<i class="pi pi-spinner pi-spin text-blue-600 text-xl"></i>
<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">
<i class="pi pi-exclamation-triangle text-red-500 mr-2"></i>
<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">
<i class="pi pi-search mx-auto text-4xl text-gray-400 dark:text-gray-500 mb-4"></i>
<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"
>
<i class="pi pi-chevron-left"></i>
</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"
>
<i class="pi pi-chevron-right"></i>
</button>
</nav>
</div>
</div>
</div>
</div>
<!-- Empty State (no filters) -->
<div v-else class="text-center py-12">
<i class="pi pi-search mx-auto text-4xl text-gray-400 dark:text-gray-500 mb-4"></i>
<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 ActiveFilterChip from '@/components/filters/ActiveFilterChip.vue'
import RideCard from '@/components/rides/RideCard.vue'
// Props
interface Props {
parkSlug?: string
}
const props = withDefaults(defineProps<Props>(), {
parkSlug: undefined,
})
// Composables
const router = useRouter()
const rideFilteringStore = useRideFilteringStore()
// Store state
const { rides, isLoading, error, totalCount, totalPages, currentPage, filters } =
storeToRefs(rideFilteringStore)
// Computed properties
const currentSort = computed({
get: () => filters.value.sort,
set: (value: string) => {
rideFilteringStore.updateFilters({ sort: value })
},
})
const hasActiveFilters = computed(() => {
const f = filters.value
return !!(
f.search ||
f.categories.length > 0 ||
f.manufacturers.length > 0 ||
f.designers.length > 0 ||
f.parks.length > 0 ||
f.status.length > 0 ||
f.heightRange[0] > 0 ||
f.heightRange[1] < 500 ||
f.speedRange[0] > 0 ||
f.speedRange[1] < 200 ||
f.capacityRange[0] > 0 ||
f.capacityRange[1] < 10000 ||
f.durationRange[0] > 0 ||
f.durationRange[1] < 600 ||
f.openingDateRange[0] ||
f.openingDateRange[1] ||
f.closingDateRange[0] ||
f.closingDateRange[1]
)
})
const activeFilterCount = computed(() => {
const f = filters.value
let count = 0
if (f.search) count++
if (f.categories.length > 0) count++
if (f.manufacturers.length > 0) count++
if (f.designers.length > 0) count++
if (f.parks.length > 0) count++
if (f.status.length > 0) count++
if (f.heightRange[0] > 0 || f.heightRange[1] < 500) count++
if (f.speedRange[0] > 0 || f.speedRange[1] < 200) count++
if (f.capacityRange[0] > 0 || f.capacityRange[1] < 10000) count++
if (f.durationRange[0] > 0 || f.durationRange[1] < 600) count++
if (f.openingDateRange[0] || f.openingDateRange[1]) count++
if (f.closingDateRange[0] || f.closingDateRange[1]) count++
return count
})
const activeFilters = computed(() => {
const f = filters.value
const active: Array<{ key: string; label: string; value: string }> = []
if (f.search) {
active.push({ key: 'search', label: 'Search', value: f.search })
}
if (f.categories.length > 0) {
active.push({
key: 'categories',
label: 'Categories',
value: `${f.categories.length} selected`,
})
}
if (f.manufacturers.length > 0) {
active.push({
key: 'manufacturers',
label: 'Manufacturers',
value: `${f.manufacturers.length} selected`,
})
}
if (f.designers.length > 0) {
active.push({
key: 'designers',
label: 'Designers',
value: `${f.designers.length} selected`,
})
}
if (f.parks.length > 0) {
active.push({ key: 'parks', label: 'Parks', value: `${f.parks.length} selected` })
}
if (f.status.length > 0) {
active.push({ key: 'status', label: 'Status', value: `${f.status.length} selected` })
}
if (f.heightRange[0] > 0 || f.heightRange[1] < 500) {
active.push({
key: 'height',
label: 'Height',
value: `${f.heightRange[0]}-${f.heightRange[1]} ft`,
})
}
if (f.speedRange[0] > 0 || f.speedRange[1] < 200) {
active.push({
key: 'speed',
label: 'Speed',
value: `${f.speedRange[0]}-${f.speedRange[1]} mph`,
})
}
if (f.capacityRange[0] > 0 || f.capacityRange[1] < 10000) {
active.push({
key: 'capacity',
label: 'Capacity',
value: `${f.capacityRange[0]}-${f.capacityRange[1]}/hr`,
})
}
if (f.durationRange[0] > 0 || f.durationRange[1] < 600) {
active.push({
key: 'duration',
label: 'Duration',
value: `${f.durationRange[0]}-${f.durationRange[1]}s`,
})
}
if (f.openingDateRange[0] || f.openingDateRange[1]) {
const start = f.openingDateRange[0] || 'earliest'
const end = f.openingDateRange[1] || 'latest'
active.push({ key: 'opening', label: 'Opening Date', value: `${start} - ${end}` })
}
if (f.closingDateRange[0] || f.closingDateRange[1]) {
const start = f.closingDateRange[0] || 'earliest'
const end = f.closingDateRange[1] || 'latest'
active.push({ key: 'closing', label: 'Closing Date', value: `${start} - ${end}` })
}
return active
})
const startItem = computed(() => {
return (currentPage.value - 1) * 20 + 1
})
const endItem = computed(() => {
return Math.min(currentPage.value * 20, totalCount.value)
})
const visiblePages = computed(() => {
const total = totalPages.value
const current = currentPage.value
const pages: number[] = []
// Always show first page
if (total >= 1) pages.push(1)
// Show pages around current page
const start = Math.max(2, current - 2)
const end = Math.min(total - 1, current + 2)
// Add ellipsis if there's a gap
if (start > 2) pages.push(-1) // -1 represents ellipsis
// Add pages around current
for (let i = start; i <= end; i++) {
if (i > 1 && i < total) pages.push(i)
}
// Add ellipsis if there's a gap
if (end < total - 1) pages.push(-1)
// Always show last page
if (total > 1) pages.push(total)
return pages
})
// Methods
const handleSortChange = () => {
rideFilteringStore.searchRides({ parkSlug: props.parkSlug })
}
const removeFilter = (filter: { key: string; label: string; value: string }) => {
const updates: any = {}
switch (filter.key) {
case 'search':
updates.search = ''
break
case 'categories':
updates.categories = []
break
case 'manufacturers':
updates.manufacturers = []
break
case 'designers':
updates.designers = []
break
case 'parks':
updates.parks = []
break
case 'status':
updates.status = []
break
case 'height':
updates.heightRange = [0, 500]
break
case 'speed':
updates.speedRange = [0, 200]
break
case 'capacity':
updates.capacityRange = [0, 10000]
break
case 'duration':
updates.durationRange = [0, 600]
break
case 'opening':
updates.openingDateRange = [null, null]
break
case 'closing':
updates.closingDateRange = [null, null]
break
}
rideFilteringStore.updateFilters(updates)
}
const clearAllFilters = () => {
rideFilteringStore.resetFilters()
}
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
rideFilteringStore.updateFilters({ page })
}
}
const retrySearch = () => {
rideFilteringStore.searchRides({ parkSlug: props.parkSlug })
}
const handleRideClick = (ride: Ride) => {
if (props.parkSlug) {
router.push(`/parks/${props.parkSlug}/rides/${ride.slug}`)
} else {
router.push(`/rides/${ride.slug}`)
}
}
// Watch for filter changes
watch(
() => filters.value,
() => {
rideFilteringStore.searchRides({ parkSlug: props.parkSlug })
},
{ deep: true },
)
// Initialize search on mount
rideFilteringStore.searchRides({ parkSlug: props.parkSlug })
</script>

View File

@@ -1,19 +0,0 @@
import '@/components/state-layer/state-layer.css'
export interface LkStateLayerProps {
bgColor?: LkColor | 'currentColor'
forcedState?: 'hover' | 'active' | 'focus' // Used when you need a static state controlled by something higher, like a select field that keeps actively-selected options grayed out
}
export default function StateLayer({ bgColor = 'currentColor', forcedState }: LkStateLayerProps) {
return (
<>
<div
data-lk-component="state-layer"
className={bgColor !== 'currentColor' ? `bg-${bgColor}` : ''}
style={bgColor === 'currentColor' ? { backgroundColor: 'currentColor' } : {}}
{...(forcedState && { 'data-lk-forced-state': forcedState })}
></div>
</>
)
}

View File

@@ -1,46 +0,0 @@
[data-lk-component='state-layer'] {
z-index: 1;
opacity: 0;
pointer-events: none;
position: absolute;
top: 0%;
bottom: 0%;
left: 0%;
right: 0%;
transition: opacity 0.1s ease-out;
}
/* Only apply styles to the [data-lk-component="state-layer"] when its direct parent is hovered, active, or focused */
div:hover > [data-lk-component='state-layer'],
a:hover > [data-lk-component='state-layer'],
button:hover > [data-lk-component='state-layer'],
li:hover > [data-lk-component='state-layer'] {
opacity: 0.16 !important;
}
div:active > [data-lk-component='state-layer'],
a:active > [data-lk-component='state-layer'],
button:active > [data-lk-component='state-layer'],
li:active > [data-lk-component='state-layer'] {
opacity: 0.5 !important;
}
div:focus > [data-lk-component='state-layer'],
a:focus > [data-lk-component='state-layer'],
button:focus > [data-lk-component='state-layer'],
li:focus > [data-lk-component='state-layer'] {
opacity: 0.35 !important;
}
.is-active [data-lk-component='state-layer'] {
opacity: 0.16 !important;
}
.list-item.active [data-lk-component='state-layer'] {
opacity: 0.24 !important;
}
[data-lk-forced-state='active'] {
opacity: 0.12 !important;
}

View File

@@ -1,107 +0,0 @@
<template>
<div class="p-6 space-y-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">PrimeVue Component Test</h2>
<!-- Theme Controller Test -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">Theme Controller</h3>
<PrimeThemeController variant="dropdown" show-text />
</div>
<!-- Button Tests -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">Buttons</h3>
<div class="flex flex-wrap gap-4">
<PrimeButton variant="primary">Primary Button</PrimeButton>
<PrimeButton variant="secondary">Secondary Button</PrimeButton>
<PrimeButton variant="outline">Outline Button</PrimeButton>
<PrimeButton variant="ghost">Ghost Button</PrimeButton>
<PrimeButton variant="destructive">Destructive Button</PrimeButton>
<PrimeButton variant="primary" loading>Loading Button</PrimeButton>
<PrimeButton variant="primary" icon-start="pi pi-search" icon-only />
</div>
</div>
<!-- Card Tests -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">Cards</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<PrimeCard title="Default Card" variant="default">
<p>This is a default card with some content.</p>
<template #footer>
<PrimeButton variant="primary" size="small">Action</PrimeButton>
</template>
</PrimeCard>
<PrimeCard title="Featured Card" variant="featured" interactive>
<p>This is a featured card with interactive hover effects.</p>
</PrimeCard>
</div>
</div>
<!-- Input Tests -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">Inputs</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<PrimeInput
v-model="testInput1"
label="Basic Input"
placeholder="Enter some text..."
helper-text="This is helper text"
/>
<PrimeInput
v-model="testInput2"
label="Input with Icons"
placeholder="Search..."
icon-start="pi pi-search"
clearable
/>
<PrimeInput
v-model="testInput3"
label="Error State"
placeholder="This has an error"
error-message="This field is required"
invalid
/>
</div>
</div>
<!-- Native PrimeVue Components Test -->
<div class="space-y-4">
<h3 class="text-lg font-semibold">Native PrimeVue Components</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Dropdown
v-model="selectedOption"
:options="dropdownOptions"
option-label="label"
option-value="value"
placeholder="Select an option"
class="w-full"
/>
<Calendar v-model="selectedDate" placeholder="Select a date" class="w-full" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { PrimeButton, PrimeCard, PrimeInput, Dropdown, Calendar } from '@/components/primevue'
import PrimeThemeController from '@/components/layout/PrimeThemeController.vue'
// Test data
const testInput1 = ref('')
const testInput2 = ref('')
const testInput3 = ref('')
const selectedOption = ref(null)
const selectedDate = ref(null)
const dropdownOptions = [
{ label: 'Option 1', value: 'opt1' },
{ label: 'Option 2', value: 'opt2' },
{ label: 'Option 3', value: 'opt3' },
]
</script>

View File

@@ -1,201 +0,0 @@
import { ref, computed } from 'vue'
import { authApi, type AuthResponse, type User } from '@/services/api'
import type { LoginCredentials, SignupCredentials } from '@/types'
// Global authentication state
const currentUser = ref<User | null>(null)
const authToken = ref<string | null>(localStorage.getItem('auth_token'))
const isLoading = ref(false)
const authError = ref<string | null>(null)
// Computed properties
const isAuthenticated = computed(() => !!currentUser.value && !!authToken.value)
// Authentication composable
export function useAuth() {
/**
* Initialize authentication state
*/
const initAuth = async () => {
const token = localStorage.getItem('auth_token')
if (token) {
authToken.value = token
authApi.setAuthToken(token)
await getCurrentUser()
}
}
/**
* Get current user information
*/
const getCurrentUser = async () => {
if (!authToken.value) return null
try {
const user = await authApi.getCurrentUser()
currentUser.value = user
return user
} catch (error) {
console.error('Failed to get current user:', error)
await logout()
return null
}
}
/**
* Login with username/email and password
*/
const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
try {
isLoading.value = true
authError.value = null
const response = await authApi.login(credentials)
// Store authentication data
authToken.value = response.token
currentUser.value = response.user
localStorage.setItem('auth_token', response.token)
return response
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Login failed'
authError.value = errorMessage
throw error
} finally {
isLoading.value = false
}
}
/**
* Register new user
*/
const signup = async (credentials: SignupCredentials): Promise<AuthResponse> => {
try {
isLoading.value = true
authError.value = null
const response = await authApi.signup(credentials)
// Store authentication data
authToken.value = response.token
currentUser.value = response.user
localStorage.setItem('auth_token', response.token)
return response
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Registration failed'
authError.value = errorMessage
throw error
} finally {
isLoading.value = false
}
}
/**
* Logout user
*/
const logout = async () => {
try {
if (authToken.value) {
await authApi.logout()
}
} catch (error) {
console.error('Logout error:', error)
} finally {
// Clear local state regardless of API call success
currentUser.value = null
authToken.value = null
localStorage.removeItem('auth_token')
authApi.setAuthToken(null)
}
}
/**
* Request password reset
*/
const requestPasswordReset = async (email: string) => {
try {
isLoading.value = true
authError.value = null
const response = await authApi.requestPasswordReset({ email })
return response
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Password reset request failed'
authError.value = errorMessage
throw error
} finally {
isLoading.value = false
}
}
/**
* Change password
*/
const changePassword = async (oldPassword: string, newPassword: string) => {
try {
isLoading.value = true
authError.value = null
const response = await authApi.changePassword({
old_password: oldPassword,
new_password: newPassword,
new_password_confirm: newPassword,
})
return response
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Password change failed'
authError.value = errorMessage
throw error
} finally {
isLoading.value = false
}
}
/**
* Get available social providers
*/
const getSocialProviders = async () => {
try {
return await authApi.getSocialProviders()
} catch (error) {
console.error('Failed to get social providers:', error)
return []
}
}
/**
* Clear authentication error
*/
const clearError = () => {
authError.value = null
}
return {
// State
currentUser: computed(() => currentUser.value),
authToken: computed(() => authToken.value),
isLoading: computed(() => isLoading.value),
authError: computed(() => authError.value),
isAuthenticated,
// Methods
initAuth,
getCurrentUser,
login,
signup,
logout,
requestPasswordReset,
changePassword,
getSocialProviders,
clearError,
}
}
// Initialize auth state on app startup
const auth = useAuth()
auth.initAuth()
export default auth

View File

@@ -1,395 +0,0 @@
/**
* 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/', 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/', 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/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/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/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/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/?${new URLSearchParams(queryParams)}`,
{
headers: {
Accept: format === 'csv' ? 'text/csv' : 'application/json',
},
},
)
if (!response.ok) throw new Error('Export failed')
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `rides_export.${format}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
} catch (err) {
console.error('Error exporting results:', err)
throw err
}
}
// Watch for filter changes and auto-fetch
watch(
() => filters.value,
(newFilters, oldFilters) => {
// Skip if this is the initial setup
if (!oldFilters) return
// Don't auto-fetch for search queries (use debounced search instead)
if (newFilters.search !== oldFilters.search) return
// Auto-fetch for other filter changes
fetchRides()
},
{ deep: true },
)
return {
// State
isLoading,
error,
rides,
totalCount,
currentPage,
hasNextPage,
hasPreviousPage,
filters,
filterOptions,
// Computed
hasActiveFilters,
isFirstLoad,
// Methods
fetchRides,
loadMore,
fetchFilterOptions,
searchCompanies,
searchRideModels,
getSearchSuggestions,
updateFilter,
updateFilters,
clearFilters,
clearFilter,
debouncedSearch,
setSorting,
setPageSize,
exportResults,
// Utilities
buildQueryParams,
}
}
// Park-specific ride filtering
export function useParkRideFiltering(parkSlug: string, initialFilters: RideFilters = {}) {
const baseComposable = useRideFiltering(initialFilters)
// Override the fetch method to use park-specific endpoint
const fetchRides = async (resetPagination = true) => {
if (resetPagination) {
baseComposable.currentPage.value = 1
baseComposable.filters.value.page = 1
}
baseComposable.isLoading.value = true
baseComposable.error.value = null
try {
const queryParams = baseComposable.buildQueryParams(baseComposable.filters.value)
const response = await api.client.get<ApiResponse<Ride>>(
`/parks/${parkSlug}/rides/`,
queryParams,
)
baseComposable.rides.value = response.results
baseComposable.totalCount.value = response.count
baseComposable.hasNextPage.value = !!response.next
baseComposable.hasPreviousPage.value = !!response.previous
return response
} catch (err) {
baseComposable.error.value = err instanceof Error ? err.message : 'Failed to fetch park rides'
console.error('Error fetching park rides:', err)
throw err
} finally {
baseComposable.isLoading.value = false
}
}
return {
...baseComposable,
fetchRides,
}
}

View File

@@ -1,146 +0,0 @@
import { ref, computed, watch, readonly } from 'vue'
import { usePrimeVue } from 'primevue/config'
import PrimeVue from 'primevue/config'
export type ThemeMode = 'light' | 'dark' | 'system'
const currentTheme = ref<ThemeMode>('system')
const systemTheme = ref<'light' | 'dark'>('light')
export function useTheme() {
// Note: usePrimeVue() should only be called after PrimeVue is installed
// Use a typed ReturnType instead of `any` to satisfy ESLint/TS rules.
let $primevue: ReturnType<typeof usePrimeVue> | null = null
// Computed property for the actual applied theme
const appliedTheme = computed(() => {
return currentTheme.value === 'system' ? systemTheme.value : currentTheme.value
})
// Apply theme to document and PrimeVue
const applyTheme = (theme: 'light' | 'dark') => {
// Update document class for Tailwind dark mode
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
// Update PrimeVue theme
if ($primevue?.changeTheme) {
const currentThemeName = theme === 'dark' ? 'thrillwiki-dark' : 'thrillwiki-light'
$primevue.changeTheme('', currentThemeName, 'theme-link', () => {
console.log(`Theme changed to ${theme}`)
})
}
}
// Get system theme preference
const getSystemTheme = (): 'light' | 'dark' => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
// Set theme
const setTheme = (theme: ThemeMode) => {
currentTheme.value = theme
localStorage.setItem('theme', theme)
if (theme === 'system') {
systemTheme.value = getSystemTheme()
applyTheme(systemTheme.value)
} else {
applyTheme(theme)
}
}
// Toggle between light and dark (skips system)
const toggleTheme = () => {
if (currentTheme.value === 'light') {
setTheme('dark')
} else {
setTheme('light')
}
}
// Initialize theme on mount
const initializeTheme = () => {
// Rely on the flag set in main.ts to ensure PrimeVue is installed before calling composables.
if ((window as Window & { __PRIMEVUE_INSTALLED__?: boolean }).__PRIMEVUE_INSTALLED__) {
try {
const $primevue = usePrimeVue()
defineExpose({
$primevue,
})
} catch (error) {
console.warn('PrimeVue not available for theme switching:', error)
}
}
const savedTheme = localStorage.getItem('theme') as ThemeMode | null
if (savedTheme && ['light', 'dark', 'system'].includes(savedTheme)) {
currentTheme.value = savedTheme
} else {
currentTheme.value = 'system'
}
// Set initial system theme
systemTheme.value = getSystemTheme()
// Apply the theme
if (currentTheme.value === 'system') {
applyTheme(systemTheme.value)
} else {
applyTheme(currentTheme.value as 'light' | 'dark')
}
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
systemTheme.value = e.matches ? 'dark' : 'light'
if (currentTheme.value === 'system') {
applyTheme(systemTheme.value)
}
}
mediaQuery.addEventListener('change', handleSystemThemeChange)
// Return cleanup function
return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange)
}
}
// Watch for theme changes
watch(appliedTheme, (newTheme) => {
applyTheme(newTheme)
})
return {
currentTheme: readonly(currentTheme),
appliedTheme,
systemTheme: readonly(systemTheme),
setTheme,
toggleTheme,
initializeTheme,
}
}
// Global theme state for use across components
let globalThemeCleanup: (() => void) | null = null
export function initializeGlobalTheme() {
if (globalThemeCleanup) {
globalThemeCleanup()
}
const { initializeTheme } = useTheme()
globalThemeCleanup = initializeTheme()
}
// Cleanup function for app unmount
export function cleanupGlobalTheme() {
if (globalThemeCleanup) {
globalThemeCleanup()
globalThemeCleanup = null
}
}

View File

@@ -1,142 +0,0 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
// Import Tailwind CSS
import './style.css'
// PrimeVue imports
import PrimeVue from 'primevue/config'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
import DialogService from 'primevue/dialogservice'
// Import PrimeVue theme and icons
import 'primeicons/primeicons.css'
import ThrillWikiTheme from './theme/primevue-theme'
import { initializeGlobalTheme } from './composables/useTheme'
const app = createApp(App)
app.use(createPinia())
app.use(router)
// Configure PrimeVue
app.use(PrimeVue, {
theme: {
preset: ThrillWikiTheme,
options: {
prefix: 'p',
darkModeSelector: '.dark',
cssLayer: false,
},
},
ripple: true,
inputStyle: 'outlined',
locale: {
startsWith: 'Starts with',
contains: 'Contains',
notContains: 'Not contains',
endsWith: 'Ends with',
equals: 'Equals',
notEquals: 'Not equals',
noFilter: 'No Filter',
lt: 'Less than',
lte: 'Less than or equal to',
gt: 'Greater than',
gte: 'Greater than or equal to',
dateIs: 'Date is',
dateIsNot: 'Date is not',
dateBefore: 'Date is before',
dateAfter: 'Date is after',
clear: 'Clear',
apply: 'Apply',
matchAll: 'Match All',
matchAny: 'Match Any',
addRule: 'Add Rule',
removeRule: 'Remove Rule',
accept: 'Yes',
reject: 'No',
choose: 'Choose',
upload: 'Upload',
cancel: 'Cancel',
completed: 'Completed',
pending: 'Pending',
dayNames: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
dayNamesShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
dayNamesMin: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
monthNames: [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
],
monthNamesShort: [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
],
chooseYear: 'Choose Year',
chooseMonth: 'Choose Month',
chooseDate: 'Choose Date',
prevDecade: 'Previous Decade',
nextDecade: 'Next Decade',
prevYear: 'Previous Year',
nextYear: 'Next Year',
prevMonth: 'Previous Month',
nextMonth: 'Next Month',
prevHour: 'Previous Hour',
nextHour: 'Next Hour',
prevMinute: 'Previous Minute',
nextMinute: 'Next Minute',
prevSecond: 'Previous Second',
nextSecond: 'Next Second',
am: 'AM',
pm: 'PM',
today: 'Today',
weekHeader: 'Wk',
firstDayOfWeek: 0,
showMonthAfterYear: false,
dateFormat: 'mm/dd/yy',
weak: 'Weak',
medium: 'Medium',
strong: 'Strong',
passwordPrompt: 'Enter a password',
emptyFilterMessage: 'No results found',
searchMessage: '{0} results are available',
selectionMessage: '{0} items selected',
emptySelectionMessage: 'No selected item',
emptySearchMessage: 'No results found',
emptyMessage: 'No available options',
},
})
// Add PrimeVue services
app.use(ToastService)
app.use(ConfirmationService)
app.use(DialogService)
// Initialize global theme
// Mark PrimeVue as installed so the composable detects it
initializeGlobalTheme()
app.mount('#app')

View File

@@ -1,104 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import ParkList from '@/views/parks/ParkList.vue'
import ParkDetail from '@/views/parks/ParkDetail.vue'
import RideList from '@/views/rides/RideList.vue'
import RideDetail from '@/views/rides/RideDetail.vue'
import SearchResults from '@/views/SearchResults.vue'
import Login from '@/views/accounts/Login.vue'
import Signup from '@/views/accounts/Signup.vue'
import ForgotPassword from '@/views/accounts/ForgotPassword.vue'
import NotFound from '@/views/NotFound.vue'
import Error from '@/views/Error.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: Home,
},
{
path: '/parks/',
name: 'park-list',
component: ParkList,
},
{
path: '/parks/:slug/',
name: 'park-detail',
component: ParkDetail,
props: true,
},
{
path: '/parks/:parkSlug/rides/',
name: 'park-ride-list',
component: RideList,
props: true,
},
{
path: '/parks/:parkSlug/rides/:rideSlug/',
name: 'ride-detail',
component: RideDetail,
props: true,
},
{
path: '/rides/',
name: 'global-ride-list',
component: RideList,
},
{
path: '/rides/:rideSlug/',
name: 'global-ride-detail',
component: RideDetail,
props: true,
},
{
path: '/search/',
name: 'search-results',
component: SearchResults,
},
{
path: '/search/parks/',
name: 'search-parks',
component: SearchResults,
props: { searchType: 'parks' },
},
{
path: '/search/rides/',
name: 'search-rides',
component: SearchResults,
props: { searchType: 'rides' },
},
// Authentication routes
{
path: '/auth/login/',
name: 'login',
component: Login,
},
{
path: '/auth/signup/',
name: 'signup',
component: Signup,
},
{
path: '/auth/forgot-password/',
name: 'forgot-password',
component: ForgotPassword,
},
// Error routes
{
path: '/error/',
name: 'error',
component: Error,
},
// 404 catch-all route (must be last)
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: NotFound,
},
],
})
export default router

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +0,0 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

View File

@@ -1,72 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '@/services/api'
import type { Park } from '@/types'
export const useParksStore = defineStore('parks', () => {
const parks = ref<Park[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
// Computed getters
const openParks = computed(() => parks.value.filter((park) => park.status === 'open'))
const seasonalParks = computed(() => parks.value.filter((park) => park.status === 'seasonal'))
const totalParks = computed(() => parks.value.length)
// Actions
const fetchParks = async () => {
isLoading.value = true
error.value = null
try {
const response = await api.parks.getParks()
parks.value = response.results
} catch (err) {
error.value = 'Failed to fetch parks'
console.error('Error fetching parks:', err)
} finally {
isLoading.value = false
}
}
const getParkBySlug = async (slug: string): Promise<Park | null> => {
try {
return await api.parks.getPark(slug)
} catch (err) {
console.error('Error fetching park by slug:', err)
return null
}
}
const searchParks = async (query: string): Promise<Park[]> => {
if (!query.trim()) return parks.value
try {
const response = await api.parks.searchParks(query)
return response.results
} catch (err) {
console.error('Error searching parks:', err)
return []
}
}
return {
// State
parks,
isLoading,
error,
// Getters
openParks,
seasonalParks,
totalParks,
// Actions
fetchParks,
getParkBySlug,
searchParks,
}
})

View File

@@ -1,498 +0,0 @@
/**
* Pinia store for ride filtering state management
*/
import { defineStore } from 'pinia'
import { ref, computed, watch, watchEffect } 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: {},
})
// UI state for component compatibility
const uiState = ref({
sidebarVisible: false,
})
// Search state for component compatibility
const searchState = ref({
query: '',
})
// Context state
const contextType = ref<'global' | 'park'>('global')
const contextValue = ref<string | null>(null)
// Results state
const rides = ref<Ride[]>([])
const totalCount = ref(0)
const currentPage = ref(1)
const hasNextPage = ref(false)
const hasPreviousPage = ref(false)
// Search state - original for internal use
const searchQuery = ref('')
const searchSuggestions = ref<any[]>([])
const showSuggestions = ref(false)
// Sync searchQuery with searchState.query for component compatibility
watchEffect(() => {
searchState.value.query = searchQuery.value
})
// Sync searchState.query back to searchQuery
watch(
() => searchState.value.query,
(newQuery) => {
if (newQuery !== searchQuery.value) {
searchQuery.value = newQuery
}
},
)
// 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)
// Component compatibility - allFilters computed property
const allFilters = computed(() => filters.value)
// 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)
}
}
// Component compatibility methods
const toggleSidebar = () => {
uiState.value.sidebarVisible = !uiState.value.sidebarVisible
}
const clearSearchQuery = () => {
searchState.value.query = ''
searchQuery.value = ''
updateFilter('search', '')
}
const setContext = (type: 'global' | 'park', value?: string) => {
contextType.value = type
contextValue.value = value || null
// Reset filters when context changes
clearAllFilters()
console.log(`Context set to: ${type}${value ? ` (${value})` : ''}`)
}
// Initialize presets from localStorage
loadPresetsFromStorage()
return {
// State
filters,
filterOptions,
isLoading,
error,
formState,
rides,
totalCount,
currentPage,
hasNextPage,
hasPreviousPage,
searchQuery,
searchSuggestions,
showSuggestions,
savedPresets,
currentPreset,
// Component compatibility state
uiState,
searchState,
// Computed
hasActiveFilters,
activeFiltersCount,
activeFiltersList,
isFilterFormOpen,
hasUnsavedChanges,
allFilters,
// 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,
// Component compatibility methods
toggleSidebar,
clearSearchQuery,
setContext,
}
})

View File

@@ -1,115 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '@/services/api'
import type { Ride } from '@/types'
export const useRidesStore = defineStore('rides', () => {
const rides = ref<Ride[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
// Computed getters
const operatingRides = computed(() => rides.value.filter((ride) => ride.status === 'operating'))
const ridesByCategory = computed(() => {
const categories: Record<string, Ride[]> = {}
rides.value.forEach((ride) => {
if (!categories[ride.category]) {
categories[ride.category] = []
}
categories[ride.category].push(ride)
})
return categories
})
const ridesByStatus = computed(() => {
const statuses: Record<string, Ride[]> = {}
rides.value.forEach((ride) => {
if (!statuses[ride.status]) {
statuses[ride.status] = []
}
statuses[ride.status].push(ride)
})
return statuses
})
const totalRides = computed(() => rides.value.length)
// Actions
const fetchRides = async () => {
isLoading.value = true
error.value = null
try {
const response = await api.rides.getRides()
rides.value = response.results
} catch (err) {
error.value = 'Failed to fetch rides'
console.error('Error fetching rides:', err)
} finally {
isLoading.value = false
}
}
const fetchRidesByPark = async (parkSlug: string): Promise<Ride[]> => {
isLoading.value = true
error.value = null
try {
const response = await api.parks.getParkRides(parkSlug)
return response.results
} catch (err) {
error.value = 'Failed to fetch park rides'
console.error('Error fetching park rides:', err)
return []
} finally {
isLoading.value = false
}
}
const getRideBySlug = async (parkSlug: string, rideSlug: string): Promise<Ride | null> => {
try {
return await api.rides.getRide(parkSlug, rideSlug)
} catch (err) {
console.error('Error fetching ride by slug:', err)
return null
}
}
const searchRides = async (query: string): Promise<Ride[]> => {
if (!query.trim()) return rides.value
try {
const response = await api.rides.searchRides(query)
return response.results
} catch (err) {
console.error('Error searching rides:', err)
return []
}
}
const getRidesByParkSlug = (parkSlug: string): Ride[] => {
return rides.value.filter((ride) => ride.parkSlug === parkSlug)
}
return {
// State
rides,
isLoading,
error,
// Getters
operatingRides,
ridesByCategory,
ridesByStatus,
totalRides,
// Actions
fetchRides,
fetchRidesByPark,
getRideBySlug,
searchRides,
getRidesByParkSlug,
}
})

View File

@@ -1,152 +0,0 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
/* Custom base styles */
@layer base {
html {
@apply scroll-smooth;
}
body {
@apply bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-200;
}
}
/* Custom component styles */
@layer components {
.btn-primary {
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200;
}
.btn-secondary {
@apply bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 font-medium py-2 px-4 rounded-lg transition-colors duration-200;
}
.card {
@apply bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm transition-colors duration-200;
}
.input {
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200;
}
}
/* Custom utilities */
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 262.1 83.3% 57.8%;
--radius: 1rem;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 210 20% 98%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 263.4 70% 50.4%;
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

Some files were not shown because too many files have changed in this diff Show More