Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-08-26 13:19:04 -04:00
parent bf7e0c0f40
commit 831be6a2ee
151 changed files with 16260 additions and 9137 deletions

View File

@@ -24,6 +24,7 @@
"@csstools/normalize.css": "^12.1.1",
"@heroicons/vue": "^2.2.0",
"@material/material-color-utilities": "^0.3.0",
"lodash-es": "^4.17.21",
"lucide-react": "^0.541.0",
"pinia": "^3.0.3",
"vue": "^3.5.19",
@@ -39,7 +40,6 @@
"@types/jsdom": "^21.1.7",
"@types/node": "^24.3.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.0.1",
"@vitest/eslint-plugin": "^1.3.4",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
@@ -58,7 +58,7 @@
"prettier": "3.6.2",
"tailwindcss": "^4.1.12",
"typescript": "~5.9.2",
"vite": "npm:rolldown-vite@^7.1.4",
"vite": "^6.0.1",
"vite-plugin-vue-devtools": "^8.0.1",
"vitest": "^3.2.4",
"vue-tsc": "^3.0.6"

5058
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -99,6 +99,8 @@ const displayValue = computed(() => {
</script>
<style scoped>
@reference "tailwindcss";
.active-filter-chip {
@apply transition-all duration-200;
}

View File

@@ -365,6 +365,8 @@ watch(
</script>
<style scoped>
@reference "tailwindcss";
.date-input {
@apply transition-colors duration-200;
}

View File

@@ -55,6 +55,8 @@ defineEmits<{
</script>
<style scoped>
@reference "tailwindcss";
.filter-section {
@apply border-b border-gray-100 dark:border-gray-700;
}

View File

@@ -219,6 +219,8 @@ if (typeof window !== "undefined") {
</script>
<style scoped>
@reference "tailwindcss";
.preset-item {
@apply transition-all duration-200;
}

View File

@@ -445,6 +445,8 @@ onMounted(() => {
</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;
}

View File

@@ -292,6 +292,8 @@ onUnmounted(() => {
</script>
<style scoped>
@reference "tailwindcss";
.search-filter {
@apply relative;
}

View File

@@ -432,6 +432,8 @@ onUnmounted(() => {
</script>
<style scoped>
@reference "tailwindcss";
.search-input {
@apply transition-colors duration-200;
}

View File

@@ -297,6 +297,8 @@ onUnmounted(() => {
</script>
<style scoped>
@reference "tailwindcss";
.select-input {
@apply transition-colors duration-200;
}

View File

@@ -147,7 +147,7 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
const params: Record<string, string> = { q: query }
if (role) params.role = role
const response = await api.client.get<CompanySearchResult[]>('/rides/api/search-companies/', params)
const response = await api.client.get<CompanySearchResult[]>('/rides/api/search/companies/', params)
return response
} catch (err) {
console.error('Error searching companies:', err)
@@ -162,7 +162,7 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
if (!query.trim()) return []
try {
const response = await api.client.get<RideModelSearchResult[]>('/rides/api/search-ride-models/', { q: query })
const response = await api.client.get<RideModelSearchResult[]>('/rides/api/search/ride-models/', { q: query })
return response
} catch (err) {
console.error('Error searching ride models:', err)
@@ -177,7 +177,7 @@ export function useRideFiltering(initialFilters: RideFilters = {}) {
if (!query.trim()) return []
try {
const response = await api.client.get<SearchSuggestion[]>('/rides/api/search-suggestions/', { q: query })
const response = await api.client.get<SearchSuggestion[]>('/rides/api/search/suggestions/', { q: query })
return response
} catch (err) {
console.error('Error getting search suggestions:', err)

View File

@@ -145,7 +145,7 @@ export interface HistoryParams {
}
// API configuration
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
// API response types
interface ApiResponse<T> {
@@ -323,28 +323,28 @@ export class ParksApi {
if (params?.search) queryParams.search = params.search
if (params?.ordering) queryParams.ordering = params.ordering
return this.client.get<ApiResponse<Park>>('/api/parks/', queryParams)
return this.client.get<ApiResponse<Park>>('/parks/', queryParams)
}
/**
* Get a single park by slug
*/
async getPark(slug: string): Promise<Park> {
return this.client.get<Park>(`/api/parks/${slug}/`)
return this.client.get<Park>(`/parks/${slug}/`)
}
/**
* Search parks
*/
async searchParks(query: string): Promise<SearchResponse<Park>> {
return this.client.get<SearchResponse<Park>>('/api/parks/search/', { q: query })
return this.client.get<SearchResponse<Park>>('/parks/', { search: query })
}
/**
* Get rides for a specific park
*/
async getParkRides(parkSlug: string): Promise<SearchResponse<Ride>> {
return this.client.get<SearchResponse<Ride>>(`/api/parks/${parkSlug}/rides/`)
return this.client.get<SearchResponse<Ride>>(`/parks/${parkSlug}/rides/`)
}
/**
@@ -361,7 +361,7 @@ export class ParksApi {
count: number
days: number
parks: Park[]
}>('/api/parks/recent_changes/', params)
}>('/parks/recent_changes/', params)
}
/**
@@ -378,7 +378,7 @@ export class ParksApi {
count: number
days: number
parks: Park[]
}>('/api/parks/recent_openings/', params)
}>('/parks/recent_openings/', params)
}
/**
@@ -395,7 +395,7 @@ export class ParksApi {
count: number
days: number
parks: Park[]
}>('/api/parks/recent_closures/', params)
}>('/parks/recent_closures/', params)
}
/**
@@ -412,7 +412,7 @@ export class ParksApi {
count: number
days: number
parks: Park[]
}>('/api/parks/recent_name_changes/', params)
}>('/parks/recent_name_changes/', params)
}
}
@@ -537,7 +537,7 @@ export class RidesApi {
count: number
days: number
rides: Ride[]
}>('/api/rides/recent_changes/', params)
}>('/rides/recent_changes/', params)
}
/**
@@ -554,7 +554,7 @@ export class RidesApi {
count: number
days: number
rides: Ride[]
}>('/api/rides/recent_openings/', params)
}>('/rides/recent_openings/', params)
}
/**
@@ -571,7 +571,7 @@ export class RidesApi {
count: number
days: number
rides: Ride[]
}>('/api/rides/recent_closures/', params)
}>('/rides/recent_closures/', params)
}
/**
@@ -588,7 +588,7 @@ export class RidesApi {
count: number
days: number
rides: Ride[]
}>('/api/rides/recent_name_changes/', params)
}>('/rides/recent_name_changes/', params)
}
/**
@@ -605,14 +605,14 @@ export class RidesApi {
count: number
days: number
rides: Ride[]
}>('/api/rides/recent_relocations/', params)
}>('/rides/recent_relocations/', params)
}
/**
* Get filter options for ride filtering
*/
async getFilterOptions(): Promise<any> {
return this.client.get('/rides/api/filter-options/')
return this.client.get('/rides/filter-options/')
}
/**
@@ -621,21 +621,21 @@ export class RidesApi {
async searchCompanies(query: string, role?: 'manufacturer' | 'designer' | 'both'): Promise<any[]> {
const params: Record<string, string> = { q: query }
if (role) params.role = role
return this.client.get('/rides/api/search-companies/', params)
return this.client.get('/rides/search-companies/', params)
}
/**
* Search ride models for autocomplete
*/
async searchRideModels(query: string): Promise<any[]> {
return this.client.get('/rides/api/search-ride-models/', { q: query })
return this.client.get('/rides/search-ride-models/', { q: query })
}
/**
* Get search suggestions
*/
async getSearchSuggestions(query: string): Promise<any[]> {
return this.client.get('/rides/api/search-suggestions/', { q: query })
return this.client.get('/rides/search-suggestions/', { q: query })
}
/**
@@ -656,7 +656,7 @@ export class RidesApi {
}
})
return this.client.get<ApiResponse<Ride>>('/rides/api/', params)
return this.client.get<ApiResponse<Ride>>('/rides/', params)
}
}
@@ -688,7 +688,7 @@ export class AuthApi {
* Login with username/email and password
*/
async login(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await this.client.post<AuthResponse>('/api/auth/login/', credentials)
const response = await this.client.post<AuthResponse>('/auth/login/', credentials)
if (response.token) {
this.client.setAuthToken(response.token)
}
@@ -699,7 +699,7 @@ export class AuthApi {
* Logout user
*/
async logout(): Promise<void> {
await this.client.post<void>('/api/auth/logout/')
await this.client.post<void>('/auth/logout/')
this.client.setAuthToken(null)
}
@@ -707,7 +707,7 @@ export class AuthApi {
* Register new user
*/
async signup(credentials: SignupCredentials): Promise<AuthResponse> {
const response = await this.client.post<AuthResponse>('/api/auth/signup/', credentials)
const response = await this.client.post<AuthResponse>('/auth/signup/', credentials)
if (response.token) {
this.client.setAuthToken(response.token)
}
@@ -718,28 +718,28 @@ export class AuthApi {
* Get current user info
*/
async getCurrentUser(): Promise<User> {
return this.client.get<User>('/api/auth/user/')
return this.client.get<User>('/auth/user/')
}
/**
* Request password reset
*/
async requestPasswordReset(data: PasswordResetRequest): Promise<{ detail: string }> {
return this.client.post<{ detail: string }>('/api/auth/password/reset/', data)
return this.client.post<{ detail: string }>('/auth/password/reset/', data)
}
/**
* Change password
*/
async changePassword(data: PasswordChangeRequest): Promise<{ detail: string }> {
return this.client.post<{ detail: string }>('/api/auth/password/change/', data)
return this.client.post<{ detail: string }>('/auth/password/change/', data)
}
/**
* Get available social auth providers
*/
async getSocialProviders(): Promise<SocialAuthProvider[]> {
return this.client.get<SocialAuthProvider[]>('/api/auth/providers/')
return this.client.get<SocialAuthProvider[]>('/auth/providers/')
}
/**
@@ -774,7 +774,7 @@ export class HistoryApi {
if (params?.model_type) queryParams.model_type = params.model_type
if (params?.significance) queryParams.significance = params.significance
return this.client.get<UnifiedHistoryTimeline>('/api/history/timeline/', queryParams)
return this.client.get<UnifiedHistoryTimeline>('/history/timeline/', queryParams)
}
/**
@@ -789,14 +789,14 @@ export class HistoryApi {
if (params?.start_date) queryParams.start_date = params.start_date
if (params?.end_date) queryParams.end_date = params.end_date
return this.client.get<HistoryEvent[]>(`/api/parks/${parkSlug}/history/`, queryParams)
return this.client.get<HistoryEvent[]>(`/parks/${parkSlug}/history/`, queryParams)
}
/**
* Get complete park history with current state and summary
*/
async getParkHistoryDetail(parkSlug: string): Promise<ParkHistoryResponse> {
return this.client.get<ParkHistoryResponse>(`/api/parks/${parkSlug}/history/detail/`)
return this.client.get<ParkHistoryResponse>(`/parks/${parkSlug}/history/detail/`)
}
/**
@@ -816,7 +816,7 @@ export class HistoryApi {
if (params?.end_date) queryParams.end_date = params.end_date
return this.client.get<HistoryEvent[]>(
`/api/parks/${parkSlug}/rides/${rideSlug}/history/`,
`/parks/${parkSlug}/rides/${rideSlug}/history/`,
queryParams
)
}
@@ -826,7 +826,7 @@ export class HistoryApi {
*/
async getRideHistoryDetail(parkSlug: string, rideSlug: string): Promise<RideHistoryResponse> {
return this.client.get<RideHistoryResponse>(
`/api/parks/${parkSlug}/rides/${rideSlug}/history/detail/`
`/parks/${parkSlug}/rides/${rideSlug}/history/detail/`
)
}
@@ -894,14 +894,14 @@ export class TrendingApi {
* Get trending content (rides, parks, reviews)
*/
async getTrendingContent(): Promise<TrendingResponse> {
return this.client.get<TrendingResponse>('/api/trending/content/')
return this.client.get<TrendingResponse>('/trending/content/')
}
/**
* Get new content (recently added, newly opened, upcoming)
*/
async getNewContent(): Promise<NewContentResponse> {
return this.client.get<NewContentResponse>('/api/trending/new/')
return this.client.get<NewContentResponse>('/trending/new/')
}
/**
@@ -983,35 +983,35 @@ export class RankingsApi {
if (params?.park) queryParams.park = params.park
if (params?.ordering) queryParams.ordering = params.ordering
return this.client.get<ApiResponse<RideRanking>>('/api/rankings/', queryParams)
return this.client.get<ApiResponse<RideRanking>>('/rankings/', queryParams)
}
/**
* Get detailed ranking information for a specific ride
*/
async getRankingDetail(rideSlug: string): Promise<RideRankingDetail> {
return this.client.get<RideRankingDetail>(`/api/rankings/${rideSlug}/`)
return this.client.get<RideRankingDetail>(`/rankings/${rideSlug}/`)
}
/**
* Get historical ranking data for a specific ride (last 90 days)
*/
async getRankingHistory(rideSlug: string): Promise<RankingSnapshot[]> {
return this.client.get<RankingSnapshot[]>(`/api/rankings/${rideSlug}/history/`)
return this.client.get<RankingSnapshot[]>(`/rankings/${rideSlug}/history/`)
}
/**
* Get head-to-head comparison data for a ride
*/
async getHeadToHeadComparisons(rideSlug: string): Promise<HeadToHeadComparison[]> {
return this.client.get<HeadToHeadComparison[]>(`/api/rankings/${rideSlug}/comparisons/`)
return this.client.get<HeadToHeadComparison[]>(`/rankings/${rideSlug}/comparisons/`)
}
/**
* Get system-wide ranking statistics
*/
async getRankingStatistics(): Promise<RankingStatistics> {
return this.client.get<RankingStatistics>('/api/rankings/statistics/')
return this.client.get<RankingStatistics>('/rankings/statistics/')
}
/**
@@ -1031,7 +1031,7 @@ export class RankingsApi {
comparisons_made: number
duration: number
timestamp: string
}>('/api/rankings/calculate/', data)
}>('/rankings/calculate/', data)
}
/**
@@ -1119,7 +1119,7 @@ export class EntitySearchApi {
if (params?.limit) queryParams.limit = params.limit.toString()
if (params?.min_confidence) queryParams.min_confidence = params.min_confidence.toString()
return this.client.get<FuzzyMatchResult>('/api/entities/fuzzy-search/', queryParams)
return this.client.get<FuzzyMatchResult>('/entities/fuzzy-search/', queryParams)
}
/**
@@ -1135,7 +1135,7 @@ export class EntitySearchApi {
if (context?.park_slug) data.park_slug = context.park_slug
if (context?.path) data.path = context.path
return this.client.post<EntityNotFoundResponse>('/api/entities/not-found/', data)
return this.client.post<EntityNotFoundResponse>('/entities/not-found/', data)
}
/**
@@ -1152,7 +1152,7 @@ export class EntitySearchApi {
if (params?.limit) queryParams.limit = params.limit.toString()
if (params?.park_context) queryParams.park_context = params.park_context
return this.client.get<QuickSuggestionResponse>('/api/entities/quick-suggestions/', queryParams)
return this.client.get<QuickSuggestionResponse>('/entities/quick-suggestions/', queryParams)
}
/**

View File

@@ -3,7 +3,7 @@
*/
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, watchEffect } from 'vue'
import type {
RideFilters,
FilterOptions,
@@ -37,6 +37,20 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
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)
@@ -44,11 +58,23 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
const hasNextPage = ref(false)
const hasPreviousPage = ref(false)
// Search state
// 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)
@@ -116,6 +142,9 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
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> = {
@@ -374,6 +403,27 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
}
}
// 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()
@@ -395,12 +445,17 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
savedPresets,
currentPreset,
// Component compatibility state
uiState,
searchState,
// Computed
hasActiveFilters,
activeFiltersCount,
activeFiltersList,
isFilterFormOpen,
hasUnsavedChanges,
allFilters,
// Actions
updateFilter,
@@ -436,6 +491,11 @@ export const useRideFilteringStore = defineStore('rideFiltering', () => {
savePreset,
loadPreset,
deletePreset,
loadPresetsFromStorage
loadPresetsFromStorage,
// Component compatibility methods
toggleSidebar,
clearSearchQuery,
setContext
}
})

View File

@@ -1,269 +1,392 @@
<template>
<div class="bg-gray-50 dark:bg-gray-900 min-h-screen">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="text-center mb-8">
<h1 class="text-3xl lg:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ parkSlug ? `${parkName} Rides` : 'All Rides' }}
</h1>
<p v-if="parkSlug" class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
Explore all the thrilling rides and attractions at {{ parkName }}.
</p>
<p v-else class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
Discover amazing rides and attractions from theme parks around the world.
</p>
</div>
<div class="flex">
<!-- Filter Sidebar -->
<RideFilterSidebar
v-if="filterStore.uiState.sidebarVisible"
class="w-80 flex-shrink-0 h-screen sticky top-0 overflow-y-auto"
/>
<!-- Breadcrumb for park-specific rides -->
<nav v-if="parkSlug" class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li>
<router-link to="/parks/" class="hover:text-blue-600 dark:hover:text-blue-400"
>Parks</router-link
>
</li>
<li>/</li>
<li>
<router-link
:to="`/parks/${parkSlug}/`"
class="hover:text-blue-600 dark:hover:text-blue-400"
>{{ parkName }}</router-link
>
</li>
<li>/</li>
<li class="font-medium text-gray-900 dark:text-white">Rides</li>
</ol>
</nav>
<!-- Search and Filters -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-8">
<div class="flex flex-col md:flex-row gap-4">
<div class="flex-1">
<input
v-model="searchQuery"
type="text"
placeholder="Search rides..."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<select
v-model="selectedCategory"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">All Categories</option>
<option value="roller-coaster">Roller Coasters</option>
<option value="water-ride">Water Rides</option>
<option value="family-ride">Family Rides</option>
<option value="thrill-ride">Thrill Rides</option>
<option value="dark-ride">Dark Rides</option>
</select>
<select
v-model="sortBy"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="name">Sort by Name</option>
<option value="rating">Sort by Rating</option>
<option value="height">Sort by Height</option>
<option value="speed">Sort by Speed</option>
</select>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading rides...</p>
</div>
<!-- Rides Grid -->
<div v-else-if="filteredRides.length > 0" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div
v-for="ride in filteredRides"
:key="ride.id"
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow cursor-pointer"
@click="navigateToRide(ride)"
>
<!-- Ride Image -->
<div class="h-48 bg-gradient-to-br from-purple-500 via-blue-500 to-teal-500 relative">
<div class="absolute inset-0 bg-black bg-opacity-20"></div>
<div class="absolute top-4 right-4">
<span
v-if="ride.featured"
class="px-2 py-1 text-xs font-medium text-white bg-yellow-500 rounded-full"
<!-- Main Content -->
<div class="flex-1 min-w-0">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div class="text-center flex-1">
<h1
class="text-3xl lg:text-4xl font-bold text-gray-900 dark:text-white mb-4"
>
Featured
</span>
</div>
<div class="absolute bottom-4 left-4">
<h3 class="text-white text-xl font-bold">{{ ride.name }}</h3>
<p class="text-white text-sm opacity-90">{{ ride.park_name }}</p>
{{ parkSlug ? `${parkName} Rides` : "All Rides" }}
</h1>
<p
v-if="parkSlug"
class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto"
>
Explore all the thrilling rides and attractions at {{ parkName }}.
</p>
<p
v-else
class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto"
>
Discover amazing rides and attractions from theme parks around the world.
</p>
</div>
<!-- Toggle Filter Sidebar Button -->
<button
@click="filterStore.toggleSidebar()"
class="ml-4 p-2 rounded-lg bg-white dark:bg-gray-800 shadow-lg hover:shadow-xl transition-shadow border border-gray-200 dark:border-gray-700"
:class="{
'text-blue-600 dark:text-blue-400': filterStore.uiState.sidebarVisible,
}"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"
></path>
</svg>
</button>
</div>
<!-- Ride Info -->
<div class="p-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<svg class="h-4 w-4 text-yellow-500 mr-1" fill="currentColor" viewBox="0 0 20 20">
<!-- Breadcrumb for park-specific rides -->
<nav v-if="parkSlug" class="mb-6" aria-label="Breadcrumb">
<ol
class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400"
>
<li>
<router-link
to="/parks/"
class="hover:text-blue-600 dark:hover:text-blue-400"
>Parks</router-link
>
</li>
<li>/</li>
<li>
<router-link
:to="`/parks/${parkSlug}/`"
class="hover:text-blue-600 dark:hover:text-blue-400"
>{{ parkName }}</router-link
>
</li>
<li>/</li>
<li class="font-medium text-gray-900 dark:text-white">Rides</li>
</ol>
</nav>
<!-- Quick Filter Bar (when sidebar is hidden) -->
<div
v-if="!filterStore.uiState.sidebarVisible"
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-8"
>
<div class="flex flex-col md:flex-row gap-4 items-center">
<div class="flex-1">
<input
v-model="filterStore.searchState.query"
type="text"
placeholder="Search rides..."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<button
@click="filterStore.toggleSidebar()"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"
></path>
</svg>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ ride.average_rating ? ride.average_rating.toFixed(1) : 'N/A' }}
</span>
</div>
<span
class="px-2 py-1 text-xs font-medium text-blue-800 bg-blue-100 dark:bg-blue-700 dark:text-blue-50 rounded-full"
>
{{ ride.category_display }}
</span>
More Filters
</button>
</div>
</div>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-2">
{{ ride.description }}
</p>
<!-- Stats -->
<div class="grid grid-cols-2 gap-4 text-sm">
<div v-if="ride.height" class="text-gray-600 dark:text-gray-400">
<span class="font-medium">Height:</span> {{ ride.height }}ft
<!-- Active Filters Display -->
<div v-if="hasActiveFilters" class="mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-medium text-gray-900 dark:text-white">Active Filters</h3>
<button
@click="filterStore.clearAllFilters()"
class="text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300"
>
Clear All
</button>
</div>
<div v-if="ride.speed" class="text-gray-600 dark:text-gray-400">
<span class="font-medium">Speed:</span> {{ ride.speed }}mph
</div>
<div v-if="ride.length" class="text-gray-600 dark:text-gray-400">
<span class="font-medium">Length:</span> {{ ride.length }}ft
</div>
<div v-if="ride.duration" class="text-gray-600 dark:text-gray-400">
<span class="font-medium">Duration:</span> {{ ride.duration }}
<div class="flex flex-wrap gap-2">
<!-- Search filter chip -->
<div
v-if="filterStore.searchState.query"
class="flex items-center gap-1 px-3 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-sm"
>
<span>Search: "{{ filterStore.searchState.query }}"</span>
<button
@click="filterStore.clearSearchQuery()"
class="ml-1 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200"
>
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
</button>
</div>
<!-- Other active filter chips will be handled by the filter store -->
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-12">
<svg
class="h-16 w-16 mx-auto text-gray-300 dark:text-gray-600 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M13 10V3L4 14h7v7l9-11h-7z"
></path>
</svg>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No rides found</h3>
<p class="text-gray-500 dark:text-gray-400">
Try adjusting your search or filter criteria.
</p>
<!-- Results Summary -->
<div class="mb-6">
<div class="flex items-center justify-between">
<p class="text-gray-600 dark:text-gray-400">
<span v-if="loading">Loading rides...</span>
<span v-else-if="error" class="text-red-600 dark:text-red-400">{{
error
}}</span>
<span v-else>
{{ totalCount }} {{ totalCount === 1 ? "ride" : "rides" }} found
<span v-if="hasActiveFilters">(filtered)</span>
</span>
</p>
<!-- Sort Controls -->
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Sort by:</label>
<select
v-model="filterStore.filters.ordering"
class="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
>
<option value="name">Name</option>
<option value="-average_rating">Rating (High to Low)</option>
<option value="-height">Height (High to Low)</option>
<option value="-speed">Speed (High to Low)</option>
<option value="park_name">Park Name</option>
<option value="category">Category</option>
</select>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<div
class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"
></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading rides...</p>
</div>
<!-- Rides Grid -->
<div
v-else-if="rides.length > 0"
class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"
>
<div
v-for="ride in rides"
:key="ride.id"
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow cursor-pointer"
@click="navigateToRide(ride)"
>
<!-- Ride Image -->
<div
class="h-48 bg-gradient-to-br from-purple-500 via-blue-500 to-teal-500 relative"
>
<div class="absolute inset-0 bg-black bg-opacity-20"></div>
<div class="absolute top-4 right-4">
<span
v-if="ride.featured"
class="px-2 py-1 text-xs font-medium text-white bg-yellow-500 rounded-full"
>
Featured
</span>
</div>
<div class="absolute bottom-4 left-4">
<h3 class="text-white text-xl font-bold">{{ ride.name }}</h3>
<p class="text-white text-sm opacity-90">{{ ride.park_name }}</p>
</div>
</div>
<!-- Ride Info -->
<div class="p-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<svg
class="h-4 w-4 text-yellow-500 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
></path>
</svg>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ ride.average_rating ? ride.average_rating.toFixed(1) : "N/A" }}
</span>
</div>
<span
class="px-2 py-1 text-xs font-medium text-blue-800 bg-blue-100 dark:bg-blue-700 dark:text-blue-50 rounded-full"
>
{{ ride.category_display }}
</span>
</div>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-2">
{{ ride.description }}
</p>
<!-- Stats -->
<div class="grid grid-cols-2 gap-4 text-sm">
<div v-if="ride.height" class="text-gray-600 dark:text-gray-400">
<span class="font-medium">Height:</span> {{ ride.height }}ft
</div>
<div v-if="ride.speed" class="text-gray-600 dark:text-gray-400">
<span class="font-medium">Speed:</span> {{ ride.speed }}mph
</div>
<div v-if="ride.length" class="text-gray-600 dark:text-gray-400">
<span class="font-medium">Length:</span> {{ ride.length }}ft
</div>
<div v-if="ride.duration" class="text-gray-600 dark:text-gray-400">
<span class="font-medium">Duration:</span> {{ ride.duration }}
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-12">
<svg
class="h-16 w-16 mx-auto text-gray-300 dark:text-gray-600 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M13 10V3L4 14h7v7l9-11h-7z"
></path>
</svg>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
No rides found
</h3>
<p class="text-gray-500 dark:text-gray-400">
Try adjusting your search or filter criteria.
</p>
<button
v-if="hasActiveFilters"
@click="filterStore.clearAllFilters()"
class="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Clear All Filters
</button>
</div>
<!-- Load More Button -->
<div v-if="rides.length > 0 && !loading" class="text-center mt-8">
<button
v-if="hasNextPage"
@click="loadMore"
:disabled="loading"
class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ loading ? "Loading..." : "Load More Rides" }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '@/services/api'
import type { Ride } from '@/types'
import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRideFiltering, useParkRideFiltering } from "@/composables/useRideFiltering";
import { useRideFilteringStore } from "@/stores/rideFiltering";
import RideFilterSidebar from "@/components/filters/RideFilterSidebar.vue";
import type { Ride } from "@/types";
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const rides = ref<Ride[]>([])
const searchQuery = ref('')
const selectedCategory = ref('')
const sortBy = ref('name')
const route = useRoute();
const router = useRouter();
// Get park context from route params
const parkSlug = computed(() => route.params.parkSlug as string)
const parkName = ref('')
const parkSlug = computed(() => route.params.parkSlug as string);
const parkName = ref("");
const filteredRides = computed(() => {
let result = rides.value
// Initialize filtering store
const filterStore = useRideFilteringStore();
// Apply search filter
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(
(ride) =>
ride.name.toLowerCase().includes(query) ||
ride.description.toLowerCase().includes(query) ||
ride.park_name.toLowerCase().includes(query),
)
}
// Use appropriate composable based on context
const rideFiltering = parkSlug.value
? useParkRideFiltering(parkSlug.value, {})
: useRideFiltering({});
// Apply category filter
if (selectedCategory.value) {
result = result.filter((ride) => ride.category === selectedCategory.value)
}
const {
isLoading: loading,
error,
rides,
totalCount,
hasNextPage,
hasActiveFilters,
fetchRides,
loadMore,
fetchFilterOptions,
} = rideFiltering;
// Apply sorting
result.sort((a, b) => {
switch (sortBy.value) {
case 'rating':
return (b.average_rating || 0) - (a.average_rating || 0)
case 'height':
return (b.height || 0) - (a.height || 0)
case 'speed':
return (b.speed || 0) - (a.speed || 0)
default:
return a.name.localeCompare(b.name)
}
})
return result
})
const fetchRides = async () => {
// Initialize filter store with context
onMounted(async () => {
try {
loading.value = true
let response
// Set context in store
if (parkSlug.value) {
// Fetch rides for specific park
response = await api.parks.getParkRides(parkSlug.value)
filterStore.setContext("park", parkSlug.value);
// Also fetch park info to get the park name
// Fetch park info to get the park name
try {
const parkData = await api.parks.getPark(parkSlug.value)
parkName.value = parkData.name
const { api } = await import("@/services/api");
const parkData = await api.parks.getPark(parkSlug.value);
parkName.value = parkData.name;
} catch (err) {
console.warn('Could not fetch park name:', err)
console.warn("Could not fetch park name:", err);
}
rides.value = response.results
} else {
// Fetch all rides
response = await api.rides.getRides({
search: searchQuery.value || undefined,
ordering: sortBy.value === 'rating' ? '-average_rating' : sortBy.value,
})
rides.value = response.results
filterStore.setContext("global");
}
// Load filter options and initial rides
await Promise.all([fetchFilterOptions(), fetchRides()]);
} catch (error) {
console.error('Failed to fetch rides:', error)
} finally {
loading.value = false
console.error("Failed to initialize ride list:", error);
}
}
});
// Watch for filter changes in store and update composable
watch(
() => filterStore.allFilters,
(newFilters) => {
// Update the composable's filters
rideFiltering.updateFilters(newFilters);
},
{ deep: true }
);
const navigateToRide = (ride: Ride) => {
router.push(`/parks/${ride.parkSlug}/rides/${ride.slug}/`)
}
onMounted(() => {
fetchRides()
})
router.push(`/parks/${ride.parkSlug}/rides/${ride.slug}/`);
};
</script>
<style scoped>

View File

@@ -2,7 +2,6 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
@@ -10,7 +9,6 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig(({ mode }) => ({
plugins: [
vue(),
vueJsx(),
vueDevTools(),
tailwindcss(),
],
@@ -22,11 +20,12 @@ export default defineConfig(({ mode }) => ({
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
target: 'http://127.0.0.1:8000/api/v1',
changeOrigin: true,
secure: false,
ws: true,
rewrite: (path) => path.replace(/^\/api/, '/api/v1')
rewrite: (path) => path.replace(/^\/api/, ''),
// Frontend calls /api/parks/ -> rewrites to /parks/ -> target adds /api/v1 -> /api/v1/parks/
}
}
},