mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:11:10 -05:00
Removed VueJS frontend and dramatically enhanced API
This commit is contained in:
649
api_endpoints_curl_commands.sh
Executable file
649
api_endpoints_curl_commands.sh
Executable 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
# 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 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")):
|
||||
# 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__))
|
||||
|
||||
408
backend/apps/api/v1/serializers/maps.py
Normal file
408
backend/apps/api/v1/serializers/maps.py
Normal 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
|
||||
155
backend/apps/api/v1/serializers/stats.py
Normal file
155
backend/apps/api/v1/serializers/stats.py
Normal 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')"
|
||||
)
|
||||
95
backend/apps/api/v1/signals.py
Normal file
95
backend/apps/api/v1/signals.py
Normal 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()
|
||||
@@ -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/",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
358
backend/apps/api/v1/views/stats.py
Normal file
358
backend/apps/api/v1/views/stats.py
Normal 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)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
48
backend/apps/core/middleware/nextjs.py
Normal file
48
backend/apps/core/middleware/nextjs.py
Normal 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
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
1537
docs/frontend-connection-guide.md
Normal file
1537
docs/frontend-connection-guide.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
3
frontend/.gitattributes
vendored
3
frontend/.gitattributes
vendored
@@ -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
36
frontend/.gitignore
vendored
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
lts/*
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -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**
|
||||
1272
frontend/bun.lock
1272
frontend/bun.lock
File diff suppressed because it is too large
Load Diff
216
frontend/components.d.ts
vendored
216
frontend/components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": ["./**/*"]
|
||||
}
|
||||
@@ -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
1
frontend/env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
5520
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
onlyBuiltDependencies:
|
||||
- vue-demi
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -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>
|
||||
@@ -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!')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
@@ -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 }
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user