mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 12:11:13 -05:00
feat: Implement Entity Suggestion Manager and Modal components
- Added EntitySuggestionManager.vue to manage entity suggestions and authentication. - Created EntitySuggestionModal.vue for displaying suggestions and adding new entities. - Integrated AuthManager for user authentication within the suggestion modal. - Enhanced signal handling in start-servers.sh for graceful shutdown of servers. - Improved server startup script to ensure proper cleanup and responsiveness to termination signals. - Added documentation for signal handling fixes and usage instructions.
This commit is contained in:
175
frontend/src/components/entity/AuthPrompt.vue
Normal file
175
frontend/src/components/entity/AuthPrompt.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<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>
|
||||
194
frontend/src/components/entity/EntitySuggestionCard.vue
Normal file
194
frontend/src/components/entity/EntitySuggestionCard.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<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>
|
||||
235
frontend/src/components/entity/EntitySuggestionManager.vue
Normal file
235
frontend/src/components/entity/EntitySuggestionManager.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<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>
|
||||
226
frontend/src/components/entity/EntitySuggestionModal.vue
Normal file
226
frontend/src/components/entity/EntitySuggestionModal.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<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>
|
||||
7
frontend/src/components/entity/index.ts
Normal file
7
frontend/src/components/entity/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// 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'
|
||||
@@ -22,144 +22,160 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed } from "vue";
|
||||
|
||||
interface CardProps {
|
||||
variant?: 'default' | 'outline' | 'ghost' | 'elevated'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
title?: string
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
hover?: boolean
|
||||
interactive?: boolean
|
||||
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<CardProps>(), {
|
||||
variant: 'default',
|
||||
size: 'md',
|
||||
padding: 'md',
|
||||
rounded: 'lg',
|
||||
shadow: 'sm',
|
||||
variant: "default",
|
||||
size: "md",
|
||||
padding: "md",
|
||||
rounded: "lg",
|
||||
shadow: "sm",
|
||||
hover: false,
|
||||
interactive: false,
|
||||
})
|
||||
bordered: true,
|
||||
});
|
||||
|
||||
// Base card classes
|
||||
const baseClasses =
|
||||
'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 transition-all duration-200'
|
||||
// Base card classes - Consistent background for both light and dark modes
|
||||
const baseClasses = "bg-white dark:bg-gray-800 transition-all duration-200 ease-in-out";
|
||||
|
||||
// Variant classes
|
||||
const variantClasses = computed(() => {
|
||||
const variants = {
|
||||
default: 'border',
|
||||
outline: 'border-2',
|
||||
ghost: 'border-0 bg-transparent dark:bg-transparent',
|
||||
elevated: 'border-0',
|
||||
}
|
||||
return variants[props.variant]
|
||||
})
|
||||
default: props.bordered ? "border border-gray-200 dark:border-gray-700" : "border-0",
|
||||
outline: "border-2 border-gray-300 dark:border-gray-600",
|
||||
ghost: "border-0 bg-transparent dark:bg-transparent",
|
||||
elevated: "border-0",
|
||||
featured:
|
||||
"border border-blue-200 dark:border-blue-800 bg-gradient-to-br from-blue-50 to-white dark:from-blue-950 dark:to-gray-800",
|
||||
};
|
||||
return variants[props.variant];
|
||||
});
|
||||
|
||||
// Shadow classes
|
||||
const shadowClasses = computed(() => {
|
||||
if (props.variant === 'ghost') return ''
|
||||
if (props.variant === "ghost") return "";
|
||||
|
||||
const shadows = {
|
||||
none: '',
|
||||
sm: 'shadow-sm',
|
||||
md: 'shadow-md',
|
||||
lg: 'shadow-lg',
|
||||
xl: 'shadow-xl',
|
||||
}
|
||||
return shadows[props.shadow]
|
||||
})
|
||||
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 hover:shadow-2xl",
|
||||
};
|
||||
return shadows[props.shadow];
|
||||
});
|
||||
|
||||
// Rounded classes
|
||||
const roundedClasses = computed(() => {
|
||||
const rounded = {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl',
|
||||
}
|
||||
return rounded[props.rounded]
|
||||
})
|
||||
none: "rounded-none",
|
||||
sm: "rounded-sm",
|
||||
md: "rounded-md",
|
||||
lg: "rounded-lg",
|
||||
xl: "rounded-xl",
|
||||
"2xl": "rounded-2xl",
|
||||
};
|
||||
return rounded[props.rounded];
|
||||
});
|
||||
|
||||
// Hover classes
|
||||
const hoverClasses = computed(() => {
|
||||
if (!props.hover && !props.interactive) return ''
|
||||
if (!props.hover && !props.interactive) return "";
|
||||
|
||||
let classes = ''
|
||||
if (props.hover) {
|
||||
classes += ' hover:shadow-md'
|
||||
if (props.variant !== 'ghost') {
|
||||
classes += ' hover:border-gray-300 dark:hover:border-gray-600'
|
||||
let classes = "";
|
||||
if (props.hover || props.interactive) {
|
||||
if (props.variant !== "ghost") {
|
||||
classes += " hover:border-gray-300 dark:hover:border-gray-600";
|
||||
}
|
||||
if (props.variant === "featured") {
|
||||
classes +=
|
||||
" hover:from-blue-100 hover:to-blue-50 dark:hover:from-blue-900 dark:hover:to-gray-700";
|
||||
}
|
||||
}
|
||||
|
||||
if (props.interactive) {
|
||||
classes += ' cursor-pointer hover:scale-[1.02] active:scale-[0.98]'
|
||||
classes +=
|
||||
" cursor-pointer hover:scale-[1.01] active:scale-[0.99] hover:-translate-y-0.5";
|
||||
}
|
||||
|
||||
return classes
|
||||
})
|
||||
return classes;
|
||||
});
|
||||
|
||||
// Padding classes for different sections
|
||||
const paddingClasses = computed(() => {
|
||||
const paddings = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
}
|
||||
return paddings[props.padding]
|
||||
})
|
||||
none: "",
|
||||
sm: "p-3",
|
||||
md: "p-4",
|
||||
lg: "p-6",
|
||||
xl: "p-8",
|
||||
};
|
||||
return paddings[props.padding];
|
||||
});
|
||||
|
||||
const headerPadding = computed(() => {
|
||||
if (props.padding === 'none') return ''
|
||||
if (props.padding === "none") return "";
|
||||
const paddings = {
|
||||
sm: 'px-3 pt-3',
|
||||
md: 'px-4 pt-4',
|
||||
lg: 'px-6 pt-6',
|
||||
}
|
||||
return paddings[props.padding]
|
||||
})
|
||||
sm: "px-3 pt-3",
|
||||
md: "px-4 pt-4",
|
||||
lg: "px-6 pt-6",
|
||||
xl: "px-8 pt-8",
|
||||
};
|
||||
return paddings[props.padding];
|
||||
});
|
||||
|
||||
const contentPadding = computed(() => {
|
||||
if (props.padding === 'none') return ''
|
||||
if (props.padding === "none") return "";
|
||||
|
||||
const hasHeader = props.title || props.$slots?.header
|
||||
const hasFooter = props.$slots?.footer
|
||||
const hasHeader = props.title || props.$slots?.header;
|
||||
const hasFooter = props.$slots?.footer;
|
||||
|
||||
let classes = ''
|
||||
let classes = "";
|
||||
|
||||
if (props.padding === 'sm') {
|
||||
classes = 'px-3'
|
||||
if (!hasHeader) classes += ' pt-3'
|
||||
if (!hasFooter) classes += ' pb-3'
|
||||
} else if (props.padding === 'md') {
|
||||
classes = 'px-4'
|
||||
if (!hasHeader) classes += ' pt-4'
|
||||
if (!hasFooter) classes += ' pb-4'
|
||||
} else if (props.padding === 'lg') {
|
||||
classes = 'px-6'
|
||||
if (!hasHeader) classes += ' pt-6'
|
||||
if (!hasFooter) classes += ' pb-6'
|
||||
if (props.padding === "sm") {
|
||||
classes = "px-3";
|
||||
if (!hasHeader) classes += " pt-3";
|
||||
if (!hasFooter) classes += " pb-3";
|
||||
} else if (props.padding === "md") {
|
||||
classes = "px-4";
|
||||
if (!hasHeader) classes += " pt-4";
|
||||
if (!hasFooter) classes += " pb-4";
|
||||
} else if (props.padding === "lg") {
|
||||
classes = "px-6";
|
||||
if (!hasHeader) classes += " pt-6";
|
||||
if (!hasFooter) classes += " pb-6";
|
||||
} else if (props.padding === "xl") {
|
||||
classes = "px-8";
|
||||
if (!hasHeader) classes += " pt-8";
|
||||
if (!hasFooter) classes += " pb-8";
|
||||
}
|
||||
|
||||
return classes
|
||||
})
|
||||
return classes;
|
||||
});
|
||||
|
||||
const footerPadding = computed(() => {
|
||||
if (props.padding === 'none') return ''
|
||||
if (props.padding === "none") return "";
|
||||
const paddings = {
|
||||
sm: 'px-3 pb-3',
|
||||
md: 'px-4 pb-4',
|
||||
lg: 'px-6 pb-6',
|
||||
}
|
||||
return paddings[props.padding]
|
||||
})
|
||||
sm: "px-3 pb-3",
|
||||
md: "px-4 pb-4",
|
||||
lg: "px-6 pb-6",
|
||||
xl: "px-8 pb-8",
|
||||
};
|
||||
return paddings[props.padding];
|
||||
});
|
||||
|
||||
// Combined classes
|
||||
const cardClasses = computed(() => {
|
||||
@@ -169,38 +185,39 @@ const cardClasses = computed(() => {
|
||||
shadowClasses.value,
|
||||
roundedClasses.value,
|
||||
hoverClasses.value,
|
||||
props.padding === 'none' ? '' : '',
|
||||
props.padding === "none" ? "" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
})
|
||||
.join(" ");
|
||||
});
|
||||
|
||||
const headerClasses = computed(() => {
|
||||
let classes = headerPadding.value
|
||||
if (props.padding !== 'none') {
|
||||
classes += ' border-b border-gray-200 dark:border-gray-700'
|
||||
let classes = headerPadding.value;
|
||||
if (props.padding !== "none") {
|
||||
classes += " border-b border-gray-200 dark:border-gray-700";
|
||||
}
|
||||
return classes
|
||||
})
|
||||
return classes;
|
||||
});
|
||||
|
||||
const contentClasses = computed(() => {
|
||||
return contentPadding.value
|
||||
})
|
||||
return contentPadding.value;
|
||||
});
|
||||
|
||||
const footerClasses = computed(() => {
|
||||
let classes = footerPadding.value
|
||||
if (props.padding !== 'none') {
|
||||
classes += ' border-t border-gray-200 dark:border-gray-700'
|
||||
let classes = footerPadding.value;
|
||||
if (props.padding !== "none") {
|
||||
classes += " border-t border-gray-200 dark:border-gray-700";
|
||||
}
|
||||
return classes
|
||||
})
|
||||
return classes;
|
||||
});
|
||||
|
||||
const titleClasses = computed(() => {
|
||||
const sizes = {
|
||||
sm: 'text-lg font-semibold',
|
||||
md: 'text-xl font-semibold',
|
||||
lg: 'text-2xl font-semibold',
|
||||
}
|
||||
return `${sizes[props.size]} text-gray-900 dark:text-gray-100`
|
||||
})
|
||||
sm: "text-lg font-semibold leading-6",
|
||||
md: "text-xl font-semibold leading-7",
|
||||
lg: "text-2xl font-semibold leading-8",
|
||||
xl: "text-3xl font-bold leading-9",
|
||||
};
|
||||
return `${sizes[props.size]} text-gray-900 dark:text-gray-100 tracking-tight`;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -12,8 +12,58 @@ import type {
|
||||
PasswordResetRequest,
|
||||
PasswordChangeRequest,
|
||||
SocialAuthProvider,
|
||||
TrendingResponse,
|
||||
NewContentResponse,
|
||||
TrendingItem,
|
||||
NewContentItem,
|
||||
RideRanking,
|
||||
RideRankingDetail,
|
||||
HeadToHeadComparison,
|
||||
RankingSnapshot,
|
||||
RankingStatistics,
|
||||
} from '@/types'
|
||||
|
||||
// Entity fuzzy matching types
|
||||
export interface EntitySuggestion {
|
||||
name: string
|
||||
slug: string
|
||||
entity_type: 'park' | 'ride' | 'company'
|
||||
match_type: 'exact' | 'fuzzy' | 'partial'
|
||||
confidence_score: number
|
||||
additional_info?: {
|
||||
park_name?: string
|
||||
opened_date?: string
|
||||
location?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface FuzzyMatchResult {
|
||||
query: string
|
||||
exact_matches: EntitySuggestion[]
|
||||
fuzzy_matches: EntitySuggestion[]
|
||||
suggestions: EntitySuggestion[]
|
||||
total_matches: number
|
||||
search_time_ms: number
|
||||
authentication_required: boolean
|
||||
can_add_entity: boolean
|
||||
}
|
||||
|
||||
export interface EntityNotFoundResponse {
|
||||
entity_name: string
|
||||
entity_type?: string
|
||||
suggestions: EntitySuggestion[]
|
||||
can_add_entity: boolean
|
||||
authentication_required: boolean
|
||||
add_entity_url?: string
|
||||
login_url: string
|
||||
signup_url: string
|
||||
}
|
||||
|
||||
export interface QuickSuggestionResponse {
|
||||
suggestions: EntitySuggestion[]
|
||||
total_available: number
|
||||
}
|
||||
|
||||
// History-specific types
|
||||
export interface HistoryEvent {
|
||||
id: string
|
||||
@@ -311,7 +361,7 @@ export class ParksApi {
|
||||
count: number
|
||||
days: number
|
||||
parks: Park[]
|
||||
}>('/api/v1/parks/recent_changes/', params)
|
||||
}>('/api/parks/recent_changes/', params)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,7 +378,7 @@ export class ParksApi {
|
||||
count: number
|
||||
days: number
|
||||
parks: Park[]
|
||||
}>('/api/v1/parks/recent_openings/', params)
|
||||
}>('/api/parks/recent_openings/', params)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,7 +395,7 @@ export class ParksApi {
|
||||
count: number
|
||||
days: number
|
||||
parks: Park[]
|
||||
}>('/api/v1/parks/recent_closures/', params)
|
||||
}>('/api/parks/recent_closures/', params)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -362,12 +412,12 @@ export class ParksApi {
|
||||
count: number
|
||||
days: number
|
||||
parks: Park[]
|
||||
}>('/api/v1/parks/recent_name_changes/', params)
|
||||
}>('/api/parks/recent_name_changes/', params)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rides API service
|
||||
* Rides API service with context-aware endpoint selection
|
||||
*/
|
||||
export class RidesApi {
|
||||
private client: ApiClient
|
||||
@@ -377,7 +427,22 @@ export class RidesApi {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rides with pagination
|
||||
* Choose appropriate endpoint based on context
|
||||
* @param parkSlug - If provided, use nested endpoint for park context
|
||||
* @param operation - The operation being performed
|
||||
* @returns The appropriate base URL
|
||||
*/
|
||||
private getEndpointUrl(parkSlug?: string, operation: 'list' | 'detail' | 'search' = 'list'): string {
|
||||
if (parkSlug && (operation === 'list' || operation === 'detail')) {
|
||||
// Use nested endpoint for park-contextual operations
|
||||
return `/api/parks/${parkSlug}/rides`
|
||||
}
|
||||
// Use global endpoint for cross-park operations or when no park context
|
||||
return '/api/rides'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rides with pagination - uses global endpoint for cross-park view
|
||||
*/
|
||||
async getRides(params?: {
|
||||
page?: number
|
||||
@@ -390,28 +455,52 @@ export class RidesApi {
|
||||
if (params?.search) queryParams.search = params.search
|
||||
if (params?.ordering) queryParams.ordering = params.ordering
|
||||
|
||||
return this.client.get<ApiResponse<Ride>>('/api/rides/', queryParams)
|
||||
return this.client.get<ApiResponse<Ride>>(`${this.getEndpointUrl()}/`, queryParams)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single ride by park and ride slug
|
||||
* Get rides for a specific park - uses nested endpoint for park context
|
||||
*/
|
||||
async getRidesByPark(parkSlug: string, params?: {
|
||||
page?: number
|
||||
search?: string
|
||||
ordering?: string
|
||||
}): Promise<ApiResponse<Ride>> {
|
||||
const queryParams: Record<string, string> = {}
|
||||
|
||||
if (params?.page) queryParams.page = params.page.toString()
|
||||
if (params?.search) queryParams.search = params.search
|
||||
if (params?.ordering) queryParams.ordering = params.ordering
|
||||
|
||||
return this.client.get<ApiResponse<Ride>>(`${this.getEndpointUrl(parkSlug, 'list')}/`, queryParams)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single ride by park and ride slug - uses nested endpoint for park context
|
||||
*/
|
||||
async getRide(parkSlug: string, rideSlug: string): Promise<Ride> {
|
||||
return this.client.get<Ride>(`/api/rides/${parkSlug}/${rideSlug}/`)
|
||||
return this.client.get<Ride>(`${this.getEndpointUrl(parkSlug, 'detail')}/${rideSlug}/`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Search rides
|
||||
* Get a ride by global ID (fallback method) - uses global endpoint
|
||||
*/
|
||||
async getRideById(rideId: string): Promise<Ride> {
|
||||
return this.client.get<Ride>(`${this.getEndpointUrl()}/${rideId}/`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Search rides globally - uses global endpoint for cross-park search
|
||||
*/
|
||||
async searchRides(query: string): Promise<SearchResponse<Ride>> {
|
||||
return this.client.get<SearchResponse<Ride>>('/api/rides/search/', { q: query })
|
||||
return this.client.get<SearchResponse<Ride>>(`${this.getEndpointUrl()}/search/`, { q: query })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rides by park
|
||||
* Search rides within a specific park - uses nested endpoint for park context
|
||||
*/
|
||||
async getRidesByPark(parkSlug: string): Promise<SearchResponse<Ride>> {
|
||||
return this.client.get<SearchResponse<Ride>>(`/api/rides/by-park/${parkSlug}/`)
|
||||
async searchRidesInPark(parkSlug: string, query: string): Promise<SearchResponse<Ride>> {
|
||||
return this.client.get<SearchResponse<Ride>>(`${this.getEndpointUrl(parkSlug, 'search')}/search/`, { q: query })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -435,7 +524,7 @@ export class RidesApi {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recently changed rides
|
||||
* Get recently changed rides - uses global endpoint for cross-park analysis
|
||||
*/
|
||||
async getRecentChanges(days?: number): Promise<{
|
||||
count: number
|
||||
@@ -448,11 +537,11 @@ export class RidesApi {
|
||||
count: number
|
||||
days: number
|
||||
rides: Ride[]
|
||||
}>('/api/v1/rides/recent_changes/', params)
|
||||
}>('/api/rides/recent_changes/', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recently opened rides
|
||||
* Get recently opened rides - uses global endpoint for cross-park analysis
|
||||
*/
|
||||
async getRecentOpenings(days?: number): Promise<{
|
||||
count: number
|
||||
@@ -465,11 +554,11 @@ export class RidesApi {
|
||||
count: number
|
||||
days: number
|
||||
rides: Ride[]
|
||||
}>('/api/v1/rides/recent_openings/', params)
|
||||
}>('/api/rides/recent_openings/', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recently closed rides
|
||||
* Get recently closed rides - uses global endpoint for cross-park analysis
|
||||
*/
|
||||
async getRecentClosures(days?: number): Promise<{
|
||||
count: number
|
||||
@@ -482,11 +571,11 @@ export class RidesApi {
|
||||
count: number
|
||||
days: number
|
||||
rides: Ride[]
|
||||
}>('/api/v1/rides/recent_closures/', params)
|
||||
}>('/api/rides/recent_closures/', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rides with recent name changes
|
||||
* Get rides with recent name changes - uses global endpoint for cross-park analysis
|
||||
*/
|
||||
async getRecentNameChanges(days?: number): Promise<{
|
||||
count: number
|
||||
@@ -499,11 +588,11 @@ export class RidesApi {
|
||||
count: number
|
||||
days: number
|
||||
rides: Ride[]
|
||||
}>('/api/v1/rides/recent_name_changes/', params)
|
||||
}>('/api/rides/recent_name_changes/', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rides that have been relocated recently
|
||||
* Get rides that have been relocated recently - uses global endpoint for cross-park analysis
|
||||
*/
|
||||
async getRecentRelocations(days?: number): Promise<{
|
||||
count: number
|
||||
@@ -516,7 +605,7 @@ export class RidesApi {
|
||||
count: number
|
||||
days: number
|
||||
rides: Ride[]
|
||||
}>('/api/v1/rides/recent_relocations/', params)
|
||||
}>('/api/rides/recent_relocations/', params)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,11 +619,25 @@ export class AuthApi {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authentication token
|
||||
*/
|
||||
setAuthToken(token: string | null): void {
|
||||
this.client.setAuthToken(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication token
|
||||
*/
|
||||
getAuthToken(): string | null {
|
||||
return this.client.getAuthToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with username/email and password
|
||||
*/
|
||||
async login(credentials: LoginCredentials): Promise<AuthResponse> {
|
||||
const response = await this.client.post<AuthResponse>('/api/accounts/login/', credentials)
|
||||
const response = await this.client.post<AuthResponse>('/api/auth/login/', credentials)
|
||||
if (response.token) {
|
||||
this.client.setAuthToken(response.token)
|
||||
}
|
||||
@@ -620,7 +723,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/v1/history/timeline/', queryParams)
|
||||
return this.client.get<UnifiedHistoryTimeline>('/api/history/timeline/', queryParams)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -635,14 +738,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/v1/parks/${parkSlug}/history/`, queryParams)
|
||||
return this.client.get<HistoryEvent[]>(`/api/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/v1/parks/${parkSlug}/history/detail/`)
|
||||
return this.client.get<ParkHistoryResponse>(`/api/parks/${parkSlug}/history/detail/`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -662,7 +765,7 @@ export class HistoryApi {
|
||||
if (params?.end_date) queryParams.end_date = params.end_date
|
||||
|
||||
return this.client.get<HistoryEvent[]>(
|
||||
`/api/v1/parks/${parkSlug}/rides/${rideSlug}/history/`,
|
||||
`/api/parks/${parkSlug}/rides/${rideSlug}/history/`,
|
||||
queryParams
|
||||
)
|
||||
}
|
||||
@@ -672,7 +775,7 @@ export class HistoryApi {
|
||||
*/
|
||||
async getRideHistoryDetail(parkSlug: string, rideSlug: string): Promise<RideHistoryResponse> {
|
||||
return this.client.get<RideHistoryResponse>(
|
||||
`/api/v1/parks/${parkSlug}/rides/${rideSlug}/history/detail/`
|
||||
`/api/parks/${parkSlug}/rides/${rideSlug}/history/detail/`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -726,6 +829,300 @@ export class HistoryApi {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trending API service for trending content and new content
|
||||
*/
|
||||
export class TrendingApi {
|
||||
private client: ApiClient
|
||||
|
||||
constructor(client: ApiClient = new ApiClient()) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trending content (rides, parks, reviews)
|
||||
*/
|
||||
async getTrendingContent(): Promise<TrendingResponse> {
|
||||
return this.client.get<TrendingResponse>('/api/trending/content/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get new content (recently added, newly opened, upcoming)
|
||||
*/
|
||||
async getNewContent(): Promise<NewContentResponse> {
|
||||
return this.client.get<NewContentResponse>('/api/trending/new/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trending rides specifically
|
||||
*/
|
||||
async getTrendingRides(): Promise<TrendingItem[]> {
|
||||
const response = await this.getTrendingContent()
|
||||
return response.trending_rides
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trending parks specifically
|
||||
*/
|
||||
async getTrendingParks(): Promise<TrendingItem[]> {
|
||||
const response = await this.getTrendingContent()
|
||||
return response.trending_parks
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest reviews specifically
|
||||
*/
|
||||
async getLatestReviews(): Promise<TrendingItem[]> {
|
||||
const response = await this.getTrendingContent()
|
||||
return response.latest_reviews
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recently added content specifically
|
||||
*/
|
||||
async getRecentlyAdded(): Promise<NewContentItem[]> {
|
||||
const response = await this.getNewContent()
|
||||
return response.recently_added
|
||||
}
|
||||
|
||||
/**
|
||||
* Get newly opened content specifically
|
||||
*/
|
||||
async getNewlyOpened(): Promise<NewContentItem[]> {
|
||||
const response = await this.getNewContent()
|
||||
return response.newly_opened
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upcoming content specifically
|
||||
*/
|
||||
async getUpcoming(): Promise<NewContentItem[]> {
|
||||
const response = await this.getNewContent()
|
||||
return response.upcoming
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ride Rankings API service
|
||||
*/
|
||||
export class RankingsApi {
|
||||
private client: ApiClient
|
||||
|
||||
constructor(client: ApiClient = new ApiClient()) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated list of ride rankings
|
||||
*/
|
||||
async getRankings(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
category?: 'RC' | 'DR' | 'FR' | 'WR' | 'TR' | 'OT'
|
||||
min_riders?: number
|
||||
park?: string
|
||||
ordering?: 'rank' | '-rank' | 'winning_percentage' | '-winning_percentage'
|
||||
}): Promise<ApiResponse<RideRanking>> {
|
||||
const queryParams: Record<string, string> = {}
|
||||
|
||||
if (params?.page) queryParams.page = params.page.toString()
|
||||
if (params?.page_size) queryParams.page_size = params.page_size.toString()
|
||||
if (params?.category) queryParams.category = params.category
|
||||
if (params?.min_riders) queryParams.min_riders = params.min_riders.toString()
|
||||
if (params?.park) queryParams.park = params.park
|
||||
if (params?.ordering) queryParams.ordering = params.ordering
|
||||
|
||||
return this.client.get<ApiResponse<RideRanking>>('/api/rankings/', queryParams)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed ranking information for a specific ride
|
||||
*/
|
||||
async getRankingDetail(rideSlug: string): Promise<RideRankingDetail> {
|
||||
return this.client.get<RideRankingDetail>(`/api/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/`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get head-to-head comparison data for a ride
|
||||
*/
|
||||
async getHeadToHeadComparisons(rideSlug: string): Promise<HeadToHeadComparison[]> {
|
||||
return this.client.get<HeadToHeadComparison[]>(`/api/rankings/${rideSlug}/comparisons/`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system-wide ranking statistics
|
||||
*/
|
||||
async getRankingStatistics(): Promise<RankingStatistics> {
|
||||
return this.client.get<RankingStatistics>('/api/rankings/statistics/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger ranking calculation (Admin only)
|
||||
*/
|
||||
async calculateRankings(category?: string): Promise<{
|
||||
status: string
|
||||
rides_ranked: number
|
||||
comparisons_made: number
|
||||
duration: number
|
||||
timestamp: string
|
||||
}> {
|
||||
const data = category ? { category } : {}
|
||||
return this.client.post<{
|
||||
status: string
|
||||
rides_ranked: number
|
||||
comparisons_made: number
|
||||
duration: number
|
||||
timestamp: string
|
||||
}>('/api/rankings/calculate/', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top N rankings (convenience method)
|
||||
*/
|
||||
async getTopRankings(limit: number = 10, category?: string): Promise<RideRanking[]> {
|
||||
const response = await this.getRankings({
|
||||
page_size: limit,
|
||||
category: category as any,
|
||||
ordering: 'rank'
|
||||
})
|
||||
return response.results
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rankings for a specific park
|
||||
*/
|
||||
async getParkRankings(parkSlug: string, params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
category?: string
|
||||
}): Promise<ApiResponse<RideRanking>> {
|
||||
return this.getRankings({
|
||||
...params,
|
||||
park: parkSlug,
|
||||
category: params?.category as any
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Search rankings by ride name
|
||||
*/
|
||||
async searchRankings(query: string): Promise<RideRanking[]> {
|
||||
// This would ideally have a dedicated search endpoint, but for now
|
||||
// we'll fetch all and filter client-side (not ideal for large datasets)
|
||||
const response = await this.getRankings({ page_size: 100 })
|
||||
return response.results.filter(ranking =>
|
||||
ranking.ride.name.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rank change information for a ride
|
||||
*/
|
||||
async getRankChange(rideSlug: string): Promise<{
|
||||
current_rank: number
|
||||
previous_rank: number | null
|
||||
change: number
|
||||
direction: 'up' | 'down' | 'same'
|
||||
}> {
|
||||
const detail = await this.getRankingDetail(rideSlug)
|
||||
const change = detail.rank_change || 0
|
||||
return {
|
||||
current_rank: detail.rank,
|
||||
previous_rank: detail.previous_rank,
|
||||
change: Math.abs(change),
|
||||
direction: change > 0 ? 'down' : change < 0 ? 'up' : 'same'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity Search API service for fuzzy matching and suggestions
|
||||
*/
|
||||
export class EntitySearchApi {
|
||||
private client: ApiClient
|
||||
|
||||
constructor(client: ApiClient = new ApiClient()) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform fuzzy search for entities
|
||||
*/
|
||||
async fuzzySearch(query: string, params?: {
|
||||
entity_types?: string[]
|
||||
context_park?: string
|
||||
limit?: number
|
||||
min_confidence?: number
|
||||
}): Promise<FuzzyMatchResult> {
|
||||
const queryParams: Record<string, string> = { q: query }
|
||||
|
||||
if (params?.entity_types) queryParams.entity_types = params.entity_types.join(',')
|
||||
if (params?.context_park) queryParams.context_park = params.context_park
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle entity not found scenarios with suggestions
|
||||
*/
|
||||
async handleNotFound(entityName: string, entityType?: string, context?: {
|
||||
park_slug?: string
|
||||
path?: string
|
||||
}): Promise<EntityNotFoundResponse> {
|
||||
const data: Record<string, any> = { entity_name: entityName }
|
||||
|
||||
if (entityType) data.entity_type = entityType
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quick suggestions for autocomplete
|
||||
*/
|
||||
async getQuickSuggestions(query: string, params?: {
|
||||
entity_types?: string[]
|
||||
limit?: number
|
||||
park_context?: string
|
||||
}): Promise<QuickSuggestionResponse> {
|
||||
const queryParams: Record<string, string> = { q: query }
|
||||
|
||||
if (params?.entity_types) queryParams.entity_types = params.entity_types.join(',')
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated and can add entities
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.client.getAuthToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication URLs for redirects
|
||||
*/
|
||||
getAuthUrls(): { login: string; signup: string } {
|
||||
const baseUrl = this.client['baseUrl']
|
||||
return {
|
||||
login: `${baseUrl}/auth/login/`,
|
||||
signup: `${baseUrl}/auth/signup/`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main API service that combines all endpoints
|
||||
*/
|
||||
@@ -734,6 +1131,9 @@ export class ThrillWikiApi {
|
||||
public rides: RidesApi
|
||||
public auth: AuthApi
|
||||
public history: HistoryApi
|
||||
public trending: TrendingApi
|
||||
public rankings: RankingsApi
|
||||
public entitySearch: EntitySearchApi
|
||||
private client: ApiClient
|
||||
|
||||
constructor() {
|
||||
@@ -742,6 +1142,9 @@ export class ThrillWikiApi {
|
||||
this.rides = new RidesApi(this.client)
|
||||
this.auth = new AuthApi(this.client)
|
||||
this.history = new HistoryApi(this.client)
|
||||
this.trending = new TrendingApi(this.client)
|
||||
this.rankings = new RankingsApi(this.client)
|
||||
this.entitySearch = new EntitySearchApi(this.client)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -785,6 +1188,9 @@ export const parksApi = api.parks
|
||||
export const ridesApi = api.rides
|
||||
export const authApi = api.auth
|
||||
export const historyApi = api.history
|
||||
export const trendingApi = api.trending
|
||||
export const rankingsApi = api.rankings
|
||||
export const entitySearchApi = api.entitySearch
|
||||
|
||||
// Export types for use in components
|
||||
export type {
|
||||
@@ -796,5 +1202,9 @@ export type {
|
||||
ParkHistoryResponse,
|
||||
RideHistoryResponse,
|
||||
UnifiedHistoryTimeline,
|
||||
HistoryParams
|
||||
HistoryParams,
|
||||
EntitySuggestion,
|
||||
FuzzyMatchResult,
|
||||
EntityNotFoundResponse,
|
||||
QuickSuggestionResponse
|
||||
}
|
||||
|
||||
@@ -179,3 +179,163 @@ export interface SocialAuthProvider {
|
||||
iconUrl?: string
|
||||
authUrl: string
|
||||
}
|
||||
|
||||
// Trending and New Content types
|
||||
export interface TrendingItem {
|
||||
id: number
|
||||
name: string
|
||||
location: string
|
||||
category: string
|
||||
rating: number
|
||||
rank: number
|
||||
views: number
|
||||
views_change: number
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface NewContentItem {
|
||||
id: number
|
||||
name: string
|
||||
location: string
|
||||
category: string
|
||||
date_added: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface TrendingResponse {
|
||||
trending_rides: TrendingItem[]
|
||||
trending_parks: TrendingItem[]
|
||||
latest_reviews: TrendingItem[]
|
||||
}
|
||||
|
||||
export interface NewContentResponse {
|
||||
recently_added: NewContentItem[]
|
||||
newly_opened: NewContentItem[]
|
||||
upcoming: NewContentItem[]
|
||||
}
|
||||
|
||||
// Enhanced Park and Ride interfaces for trending support
|
||||
export interface ParkWithTrending extends Park {
|
||||
rating?: number
|
||||
rank?: number
|
||||
views?: number
|
||||
views_change?: number
|
||||
date_added?: string
|
||||
}
|
||||
|
||||
export interface RideWithTrending extends Ride {
|
||||
rating?: number
|
||||
rank?: number
|
||||
views?: number
|
||||
views_change?: number
|
||||
date_added?: string
|
||||
}
|
||||
|
||||
// Ride Ranking types
|
||||
export interface RideRanking {
|
||||
id: number
|
||||
rank: number
|
||||
ride: {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
park: {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
category: 'RC' | 'DR' | 'FR' | 'WR' | 'TR' | 'OT'
|
||||
}
|
||||
wins: number
|
||||
losses: number
|
||||
ties: number
|
||||
winning_percentage: number
|
||||
mutual_riders_count: number
|
||||
comparison_count: number
|
||||
average_rating: number
|
||||
last_calculated: string
|
||||
rank_change?: number
|
||||
previous_rank?: number | null
|
||||
}
|
||||
|
||||
export interface RideRankingDetail extends RideRanking {
|
||||
ride: {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
description?: string
|
||||
park: {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
location?: {
|
||||
city?: string
|
||||
state?: string
|
||||
country?: string
|
||||
}
|
||||
}
|
||||
category: 'RC' | 'DR' | 'FR' | 'WR' | 'TR' | 'OT'
|
||||
manufacturer?: {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
opening_date?: string
|
||||
status: string
|
||||
}
|
||||
calculation_version?: string
|
||||
head_to_head_comparisons?: HeadToHeadComparison[]
|
||||
ranking_history?: RankingSnapshot[]
|
||||
}
|
||||
|
||||
export interface HeadToHeadComparison {
|
||||
opponent: {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
park: string
|
||||
}
|
||||
wins: number
|
||||
losses: number
|
||||
ties: number
|
||||
result: 'win' | 'loss' | 'tie'
|
||||
mutual_riders: number
|
||||
}
|
||||
|
||||
export interface RankingSnapshot {
|
||||
date: string
|
||||
rank: number
|
||||
winning_percentage: number
|
||||
}
|
||||
|
||||
export interface RankingStatistics {
|
||||
total_ranked_rides: number
|
||||
total_comparisons: number
|
||||
last_calculation_time: string
|
||||
calculation_duration: number
|
||||
top_rated_ride?: {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
park: string
|
||||
rank: number
|
||||
winning_percentage: number
|
||||
average_rating: number
|
||||
}
|
||||
most_compared_ride?: {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
park: string
|
||||
comparison_count: number
|
||||
}
|
||||
biggest_rank_change?: {
|
||||
ride: {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
current_rank: number
|
||||
previous_rank: number
|
||||
change: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,16 @@
|
||||
<div class="text-center max-w-4xl mx-auto">
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-6">Discover Your Next Thrill</h1>
|
||||
<p class="text-xl md:text-2xl mb-8 text-purple-100">
|
||||
Search through thousands of amusement rides and parks in an expansive community database
|
||||
Search through thousands of amusement rides and parks in an expansive
|
||||
community database
|
||||
</p>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="max-w-2xl mx-auto mb-16">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-gray-400"
|
||||
fill="none"
|
||||
@@ -77,8 +80,14 @@
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<svg class="h-6 w-6 text-orange-500 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2L13.09 8.26L22 9L13.09 9.74L12 16L10.91 9.74L2 9L10.91 8.26L12 2Z" />
|
||||
<svg
|
||||
class="h-6 w-6 text-orange-500 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 2L13.09 8.26L22 9L13.09 9.74L12 16L10.91 9.74L2 9L10.91 8.26L12 2Z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white">
|
||||
What's Trending
|
||||
@@ -109,19 +118,70 @@
|
||||
</div>
|
||||
|
||||
<!-- Trending Content -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoadingTrending" class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="'trending-skeleton-' + i"
|
||||
class="bg-white dark:bg-gray-700 rounded-lg shadow-lg overflow-hidden animate-pulse"
|
||||
>
|
||||
<div class="h-48 bg-gray-200 dark:bg-gray-600"></div>
|
||||
<div class="p-6">
|
||||
<div class="h-6 bg-gray-200 dark:bg-gray-600 rounded mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded mb-3 w-2/3"></div>
|
||||
<div class="flex justify-between">
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded w-1/4"></div>
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded w-1/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="trendingError" class="mb-8">
|
||||
<div
|
||||
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6 text-center"
|
||||
>
|
||||
<svg
|
||||
class="h-12 w-12 text-red-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.314 15.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-red-800 dark:text-red-200 mb-2">
|
||||
Failed to Load Trending Content
|
||||
</h3>
|
||||
<p class="text-red-600 dark:text-red-400 mb-4">{{ trendingError }}</p>
|
||||
<button
|
||||
@click="fetchTrendingContent()"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content State -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<article
|
||||
v-for="item in getTrendingContent()"
|
||||
:key="item.id"
|
||||
class="bg-white dark:bg-gray-700 rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow cursor-pointer group"
|
||||
class="bg-white dark:bg-gray-800 rounded-xl shadow-sm hover:shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden transition-all duration-200 cursor-pointer group hover:scale-[1.01] hover:-translate-y-0.5"
|
||||
@click="viewTrendingItem(item)"
|
||||
>
|
||||
<!-- Image placeholder -->
|
||||
<div
|
||||
class="h-48 bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-600 dark:to-gray-700 flex items-center justify-center relative"
|
||||
class="h-48 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 flex items-center justify-center relative"
|
||||
>
|
||||
<svg
|
||||
class="h-12 w-12 text-gray-400 dark:text-gray-500"
|
||||
class="h-10 w-10 text-gray-400 dark:text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -129,22 +189,28 @@
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1"
|
||||
stroke-width="1.5"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Trending badge -->
|
||||
<div class="absolute top-3 left-3">
|
||||
<span class="bg-orange-500 text-white text-xs font-medium px-2 py-1 rounded-full">
|
||||
<div class="absolute top-4 left-4">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-orange-500 text-white shadow-sm"
|
||||
>
|
||||
#{{ item.rank }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Rating badge -->
|
||||
<div class="absolute top-3 right-3">
|
||||
<div class="absolute top-4 right-4">
|
||||
<span
|
||||
class="bg-gray-900 bg-opacity-75 text-white text-xs font-medium px-2 py-1 rounded-full flex items-center"
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-black/75 text-white backdrop-blur-sm"
|
||||
>
|
||||
<svg class="h-3 w-3 text-yellow-400 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg
|
||||
class="h-3 w-3 text-yellow-400 mr-1 flex-shrink-0"
|
||||
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"
|
||||
/>
|
||||
@@ -156,19 +222,28 @@
|
||||
|
||||
<div class="p-6">
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
|
||||
class="text-xl font-semibold text-gray-900 dark:text-white mb-3 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors leading-tight"
|
||||
>
|
||||
{{ item.name }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
<span class="font-medium">{{ item.location }}</span>
|
||||
<span v-if="item.category"> • {{ item.category }}</span>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 leading-relaxed">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
item.location
|
||||
}}</span>
|
||||
<span v-if="item.category" class="text-gray-500 dark:text-gray-500">
|
||||
• {{ item.category }}</span
|
||||
>
|
||||
</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<span
|
||||
class="text-green-600 dark:text-green-400 text-sm font-medium flex items-center"
|
||||
class="inline-flex items-center text-sm font-medium text-green-600 dark:text-green-400"
|
||||
>
|
||||
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
class="h-4 w-4 mr-1.5 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -179,13 +254,13 @@
|
||||
+{{ item.views_change }}%
|
||||
</span>
|
||||
<span
|
||||
class="text-blue-600 dark:text-blue-400 text-sm font-medium group-hover:underline"
|
||||
class="text-sm font-medium text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ item.views }} views
|
||||
{{ item.views.toLocaleString() }} views
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -195,10 +270,18 @@
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<svg class="h-6 w-6 text-blue-500 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2L15.09 8.26L22 9L15.09 9.74L12 16L8.91 9.74L2 9L8.91 8.26L12 2Z" />
|
||||
<svg
|
||||
class="h-6 w-6 text-blue-500 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 2L15.09 8.26L22 9L15.09 9.74L12 16L8.91 9.74L2 9L8.91 8.26L12 2Z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white">What's New</h2>
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white">
|
||||
What's New
|
||||
</h2>
|
||||
</div>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Stay up to date with the latest additions and upcoming attractions
|
||||
@@ -225,19 +308,70 @@
|
||||
</div>
|
||||
|
||||
<!-- New Content -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoadingNew" class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
||||
<div
|
||||
v-for="i in 2"
|
||||
:key="'new-skeleton-' + i"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden animate-pulse"
|
||||
>
|
||||
<div class="h-48 bg-gray-200 dark:bg-gray-600"></div>
|
||||
<div class="p-6">
|
||||
<div class="h-6 bg-gray-200 dark:bg-gray-600 rounded mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded mb-3 w-2/3"></div>
|
||||
<div class="flex justify-between">
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded w-1/3"></div>
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-600 rounded w-1/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="newError" class="mb-8">
|
||||
<div
|
||||
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6 text-center"
|
||||
>
|
||||
<svg
|
||||
class="h-12 w-12 text-red-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.314 15.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-red-800 dark:text-red-200 mb-2">
|
||||
Failed to Load New Content
|
||||
</h3>
|
||||
<p class="text-red-600 dark:text-red-400 mb-4">{{ newError }}</p>
|
||||
<button
|
||||
@click="fetchNewContent()"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content State -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
||||
<article
|
||||
v-for="item in getNewContent()"
|
||||
:key="item.id"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow cursor-pointer group"
|
||||
class="bg-white dark:bg-gray-800 rounded-xl shadow-sm hover:shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden transition-all duration-200 cursor-pointer group hover:scale-[1.01] hover:-translate-y-0.5"
|
||||
@click="viewNewItem(item)"
|
||||
>
|
||||
<!-- Image placeholder -->
|
||||
<div
|
||||
class="h-48 bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-600 dark:to-gray-700 flex items-center justify-center relative"
|
||||
class="h-48 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 flex items-center justify-center relative"
|
||||
>
|
||||
<svg
|
||||
class="h-12 w-12 text-gray-400 dark:text-gray-500"
|
||||
class="h-10 w-10 text-gray-400 dark:text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -245,13 +379,15 @@
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1"
|
||||
stroke-width="1.5"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<!-- New badge -->
|
||||
<div class="absolute top-3 left-3">
|
||||
<span class="bg-green-500 text-white text-xs font-medium px-2 py-1 rounded-full">
|
||||
<div class="absolute top-4 left-4">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-500 text-white shadow-sm"
|
||||
>
|
||||
New
|
||||
</span>
|
||||
</div>
|
||||
@@ -259,26 +395,30 @@
|
||||
|
||||
<div class="p-6">
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
|
||||
class="text-xl font-semibold text-gray-900 dark:text-white mb-3 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors leading-tight"
|
||||
>
|
||||
{{ item.name }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
<span class="font-medium">{{ item.location }}</span>
|
||||
<span v-if="item.category"> • {{ item.category }}</span>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 leading-relaxed">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
item.location
|
||||
}}</span>
|
||||
<span v-if="item.category" class="text-gray-500 dark:text-gray-500">
|
||||
• {{ item.category }}</span
|
||||
>
|
||||
</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 font-medium">
|
||||
Added {{ item.date_added }}
|
||||
</span>
|
||||
<span
|
||||
class="text-blue-600 dark:text-blue-400 text-sm font-medium group-hover:underline"
|
||||
class="text-sm font-medium text-blue-600 dark:text-blue-400 group-hover:text-blue-700 dark:group-hover:text-blue-300 transition-colors"
|
||||
>
|
||||
View All New Additions →
|
||||
Learn More →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -290,8 +430,8 @@
|
||||
Join the ThrillWiki Community
|
||||
</h2>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto mb-8">
|
||||
Share your experiences, contribute to our database, and connect with fellow theme park
|
||||
enthusiasts.
|
||||
Share your experiences, contribute to our database, and connect with fellow
|
||||
theme park enthusiasts.
|
||||
</p>
|
||||
<button
|
||||
class="px-8 py-4 bg-gray-900 hover:bg-gray-800 dark:bg-gray-700 dark:hover:bg-gray-600 text-white font-medium rounded-lg transition-colors"
|
||||
@@ -304,13 +444,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { trendingApi } from "@/services/api";
|
||||
import type { TrendingItem, NewContentItem } from "@/types";
|
||||
|
||||
const router = useRouter()
|
||||
const heroSearchQuery = ref('')
|
||||
const activeTrendingTab = ref('rides')
|
||||
const activeNewTab = ref('recently-added')
|
||||
const router = useRouter();
|
||||
const heroSearchQuery = ref("");
|
||||
const activeTrendingTab = ref("rides");
|
||||
const activeNewTab = ref("recently-added");
|
||||
|
||||
// Loading states
|
||||
const isLoadingTrending = ref(true);
|
||||
const isLoadingNew = ref(true);
|
||||
const trendingError = ref<string | null>(null);
|
||||
const newError = ref<string | null>(null);
|
||||
|
||||
// Sample data matching the design
|
||||
const stats = ref({
|
||||
@@ -318,236 +466,282 @@ const stats = ref({
|
||||
rides: 10,
|
||||
reviews: 20,
|
||||
photos: 1,
|
||||
})
|
||||
});
|
||||
|
||||
const trendingTabs = [
|
||||
{ id: 'rides', label: 'Trending Rides' },
|
||||
{ id: 'parks', label: 'Trending Parks' },
|
||||
{ id: 'reviews', label: 'Latest Reviews' },
|
||||
]
|
||||
{ id: "rides", label: "Trending Rides" },
|
||||
{ id: "parks", label: "Trending Parks" },
|
||||
{ id: "reviews", label: "Latest Reviews" },
|
||||
];
|
||||
|
||||
const newTabs = [
|
||||
{ id: 'recently-added', label: 'Recently Added' },
|
||||
{ id: 'newly-opened', label: 'Newly Opened' },
|
||||
{ id: 'upcoming', label: 'Upcoming' },
|
||||
]
|
||||
{ id: "recently-added", label: "Recently Added" },
|
||||
{ id: "newly-opened", label: "Newly Opened" },
|
||||
{ id: "upcoming", label: "Upcoming" },
|
||||
];
|
||||
|
||||
const trendingRides = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Steel Vengeance',
|
||||
location: 'Cedar Point',
|
||||
category: 'Hybrid Coaster',
|
||||
rating: 4.9,
|
||||
rank: 1,
|
||||
views: 4820,
|
||||
views_change: 23,
|
||||
slug: 'steel-vengeance',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Kingda Ka',
|
||||
location: 'Six Flags Great Adventure',
|
||||
category: 'Launched Coaster',
|
||||
rating: 4.8,
|
||||
rank: 2,
|
||||
views: 3647,
|
||||
views_change: 18,
|
||||
slug: 'kingda-ka',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Pirates of the Caribbean',
|
||||
location: 'Disneyland',
|
||||
category: 'Dark Ride',
|
||||
rating: 4.7,
|
||||
rank: 3,
|
||||
views: 3156,
|
||||
views_change: 12,
|
||||
slug: 'pirates-of-the-caribbean',
|
||||
},
|
||||
])
|
||||
|
||||
const trendingParks = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Cedar Point',
|
||||
location: 'Sandusky, Ohio',
|
||||
category: 'Amusement Park',
|
||||
rating: 4.8,
|
||||
rank: 1,
|
||||
views: 8920,
|
||||
views_change: 15,
|
||||
slug: 'cedar-point',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Magic Kingdom',
|
||||
location: 'Orlando, Florida',
|
||||
category: 'Theme Park',
|
||||
rating: 4.9,
|
||||
rank: 2,
|
||||
views: 7654,
|
||||
views_change: 12,
|
||||
slug: 'magic-kingdom',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Europa-Park',
|
||||
location: 'Rust, Germany',
|
||||
category: 'Theme Park',
|
||||
rating: 4.7,
|
||||
rank: 3,
|
||||
views: 5432,
|
||||
views_change: 22,
|
||||
slug: 'europa-park',
|
||||
},
|
||||
])
|
||||
|
||||
const latestReviews = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Steel Vengeance Review',
|
||||
location: 'Cedar Point',
|
||||
category: 'Roller Coaster',
|
||||
rating: 5.0,
|
||||
rank: 1,
|
||||
views: 1234,
|
||||
views_change: 45,
|
||||
slug: 'steel-vengeance-review',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Kingda Ka Experience',
|
||||
location: 'Six Flags Great Adventure',
|
||||
category: 'Launch Coaster',
|
||||
rating: 4.8,
|
||||
rank: 2,
|
||||
views: 987,
|
||||
views_change: 32,
|
||||
slug: 'kingda-ka-review',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Pirates Ride Review',
|
||||
location: 'Disneyland',
|
||||
category: 'Dark Ride',
|
||||
rating: 4.6,
|
||||
rank: 3,
|
||||
views: 765,
|
||||
views_change: 28,
|
||||
slug: 'pirates-review',
|
||||
},
|
||||
])
|
||||
|
||||
const recentlyAdded = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Guardians of the Galaxy: Cosmic Rewind',
|
||||
location: 'EPCOT',
|
||||
category: 'Indoor Coaster',
|
||||
date_added: '2024-01-20',
|
||||
slug: 'guardians-cosmic-rewind',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'VelociCoaster',
|
||||
location: "Universal's Islands of Adventure",
|
||||
category: 'Launch Coaster',
|
||||
date_added: '2024-01-18',
|
||||
slug: 'velocicoaster',
|
||||
},
|
||||
])
|
||||
|
||||
const newlyOpened = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'TRON Lightcycle / Run',
|
||||
location: 'Magic Kingdom',
|
||||
category: 'Launch Coaster',
|
||||
date_added: '2023-04-04',
|
||||
slug: 'tron-lightcycle-run',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Hagrid's Magical Creatures Motorbike Adventure",
|
||||
location: "Universal's Islands of Adventure",
|
||||
category: 'Story Coaster',
|
||||
date_added: '2019-06-13',
|
||||
slug: 'hagrids-motorbike-adventure',
|
||||
},
|
||||
])
|
||||
|
||||
const upcoming = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Epic Universe',
|
||||
location: 'Universal Orlando',
|
||||
category: 'Theme Park',
|
||||
date_added: 'Opening 2025',
|
||||
slug: 'epic-universe',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'New Fantasyland Expansion',
|
||||
location: 'Magic Kingdom',
|
||||
category: 'Land Expansion',
|
||||
date_added: 'Opening 2026',
|
||||
slug: 'fantasyland-expansion',
|
||||
},
|
||||
])
|
||||
// Data from API
|
||||
const trendingRides = ref<TrendingItem[]>([]);
|
||||
const trendingParks = ref<TrendingItem[]>([]);
|
||||
const latestReviews = ref<TrendingItem[]>([]);
|
||||
const recentlyAdded = ref<NewContentItem[]>([]);
|
||||
const newlyOpened = ref<NewContentItem[]>([]);
|
||||
const upcoming = ref<NewContentItem[]>([]);
|
||||
|
||||
// Methods
|
||||
const handleHeroSearch = () => {
|
||||
if (heroSearchQuery.value.trim()) {
|
||||
router.push({
|
||||
name: 'search-results',
|
||||
name: "search-results",
|
||||
query: { q: heroSearchQuery.value.trim() },
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendingContent = () => {
|
||||
switch (activeTrendingTab.value) {
|
||||
case 'rides':
|
||||
return trendingRides.value
|
||||
case 'parks':
|
||||
return trendingParks.value
|
||||
case 'reviews':
|
||||
return latestReviews.value
|
||||
case "rides":
|
||||
return trendingRides.value;
|
||||
case "parks":
|
||||
return trendingParks.value;
|
||||
case "reviews":
|
||||
return latestReviews.value;
|
||||
default:
|
||||
return trendingRides.value
|
||||
return trendingRides.value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getNewContent = () => {
|
||||
switch (activeNewTab.value) {
|
||||
case 'recently-added':
|
||||
return recentlyAdded.value
|
||||
case 'newly-opened':
|
||||
return newlyOpened.value
|
||||
case 'upcoming':
|
||||
return upcoming.value
|
||||
case "recently-added":
|
||||
return recentlyAdded.value;
|
||||
case "newly-opened":
|
||||
return newlyOpened.value;
|
||||
case "upcoming":
|
||||
return upcoming.value;
|
||||
default:
|
||||
return recentlyAdded.value
|
||||
return recentlyAdded.value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const viewTrendingItem = (item: any) => {
|
||||
if (activeTrendingTab.value === 'parks') {
|
||||
router.push({ name: 'park-detail', params: { slug: item.slug } })
|
||||
} else if (activeTrendingTab.value === 'rides') {
|
||||
router.push({ name: 'global-ride-detail', params: { rideSlug: item.slug } })
|
||||
if (activeTrendingTab.value === "parks") {
|
||||
router.push({ name: "park-detail", params: { slug: item.slug } });
|
||||
} else if (activeTrendingTab.value === "rides") {
|
||||
router.push({ name: "global-ride-detail", params: { rideSlug: item.slug } });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const viewNewItem = (item: any) => {
|
||||
router.push({ name: 'park-detail', params: { slug: item.slug } })
|
||||
}
|
||||
router.push({ name: "park-detail", params: { slug: item.slug } });
|
||||
};
|
||||
|
||||
// In a real app, you would fetch this data from your Django API
|
||||
onMounted(() => {
|
||||
// TODO: Fetch actual data from Django backend
|
||||
console.log('Home view mounted')
|
||||
})
|
||||
// API calls
|
||||
const fetchTrendingContent = async () => {
|
||||
try {
|
||||
isLoadingTrending.value = true;
|
||||
trendingError.value = null;
|
||||
|
||||
const response = await trendingApi.getTrendingContent();
|
||||
trendingRides.value = response.trending_rides;
|
||||
trendingParks.value = response.trending_parks;
|
||||
latestReviews.value = response.latest_reviews;
|
||||
} catch (error) {
|
||||
console.error("Error fetching trending content:", error);
|
||||
trendingError.value = "Failed to load trending content";
|
||||
|
||||
// Fallback to sample data on error
|
||||
trendingRides.value = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Steel Vengeance",
|
||||
location: "Cedar Point",
|
||||
category: "Hybrid Coaster",
|
||||
rating: 4.9,
|
||||
rank: 1,
|
||||
views: 4820,
|
||||
views_change: 23,
|
||||
slug: "steel-vengeance",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Kingda Ka",
|
||||
location: "Six Flags Great Adventure",
|
||||
category: "Launched Coaster",
|
||||
rating: 4.8,
|
||||
rank: 2,
|
||||
views: 3647,
|
||||
views_change: 18,
|
||||
slug: "kingda-ka",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Pirates of the Caribbean",
|
||||
location: "Disneyland",
|
||||
category: "Dark Ride",
|
||||
rating: 4.7,
|
||||
rank: 3,
|
||||
views: 3156,
|
||||
views_change: 12,
|
||||
slug: "pirates-of-the-caribbean",
|
||||
},
|
||||
];
|
||||
|
||||
trendingParks.value = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Cedar Point",
|
||||
location: "Sandusky, Ohio",
|
||||
category: "Amusement Park",
|
||||
rating: 4.8,
|
||||
rank: 1,
|
||||
views: 8920,
|
||||
views_change: 15,
|
||||
slug: "cedar-point",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Magic Kingdom",
|
||||
location: "Orlando, Florida",
|
||||
category: "Theme Park",
|
||||
rating: 4.9,
|
||||
rank: 2,
|
||||
views: 7654,
|
||||
views_change: 12,
|
||||
slug: "magic-kingdom",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Europa-Park",
|
||||
location: "Rust, Germany",
|
||||
category: "Theme Park",
|
||||
rating: 4.7,
|
||||
rank: 3,
|
||||
views: 5432,
|
||||
views_change: 22,
|
||||
slug: "europa-park",
|
||||
},
|
||||
];
|
||||
|
||||
latestReviews.value = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Steel Vengeance Review",
|
||||
location: "Cedar Point",
|
||||
category: "Roller Coaster",
|
||||
rating: 5.0,
|
||||
rank: 1,
|
||||
views: 1234,
|
||||
views_change: 45,
|
||||
slug: "steel-vengeance-review",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Kingda Ka Experience",
|
||||
location: "Six Flags Great Adventure",
|
||||
category: "Launch Coaster",
|
||||
rating: 4.8,
|
||||
rank: 2,
|
||||
views: 987,
|
||||
views_change: 32,
|
||||
slug: "kingda-ka-review",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Pirates Ride Review",
|
||||
location: "Disneyland",
|
||||
category: "Dark Ride",
|
||||
rating: 4.6,
|
||||
rank: 3,
|
||||
views: 765,
|
||||
views_change: 28,
|
||||
slug: "pirates-review",
|
||||
},
|
||||
];
|
||||
} finally {
|
||||
isLoadingTrending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNewContent = async () => {
|
||||
try {
|
||||
isLoadingNew.value = true;
|
||||
newError.value = null;
|
||||
|
||||
const response = await trendingApi.getNewContent();
|
||||
recentlyAdded.value = response.recently_added;
|
||||
newlyOpened.value = response.newly_opened;
|
||||
upcoming.value = response.upcoming;
|
||||
} catch (error) {
|
||||
console.error("Error fetching new content:", error);
|
||||
newError.value = "Failed to load new content";
|
||||
|
||||
// Fallback to sample data on error
|
||||
recentlyAdded.value = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Guardians of the Galaxy: Cosmic Rewind",
|
||||
location: "EPCOT",
|
||||
category: "Indoor Coaster",
|
||||
date_added: "2024-01-20",
|
||||
slug: "guardians-cosmic-rewind",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "VelociCoaster",
|
||||
location: "Universal's Islands of Adventure",
|
||||
category: "Launch Coaster",
|
||||
date_added: "2024-01-18",
|
||||
slug: "velocicoaster",
|
||||
},
|
||||
];
|
||||
|
||||
newlyOpened.value = [
|
||||
{
|
||||
id: 1,
|
||||
name: "TRON Lightcycle / Run",
|
||||
location: "Magic Kingdom",
|
||||
category: "Launch Coaster",
|
||||
date_added: "2023-04-04",
|
||||
slug: "tron-lightcycle-run",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Hagrid's Magical Creatures Motorbike Adventure",
|
||||
location: "Universal's Islands of Adventure",
|
||||
category: "Story Coaster",
|
||||
date_added: "2019-06-13",
|
||||
slug: "hagrids-motorbike-adventure",
|
||||
},
|
||||
];
|
||||
|
||||
upcoming.value = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Epic Universe",
|
||||
location: "Universal Orlando",
|
||||
category: "Theme Park",
|
||||
date_added: "Opening 2025",
|
||||
slug: "epic-universe",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "New Fantasyland Expansion",
|
||||
location: "Magic Kingdom",
|
||||
category: "Land Expansion",
|
||||
date_added: "Opening 2026",
|
||||
slug: "fantasyland-expansion",
|
||||
},
|
||||
];
|
||||
} finally {
|
||||
isLoadingNew.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
console.log("Home view mounted - fetching trending data from API");
|
||||
|
||||
// Fetch both trending and new content in parallel
|
||||
await Promise.all([fetchTrendingContent(), fetchNewContent()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user