Champion page improvements
This commit is contained in:
@@ -11,20 +11,43 @@ const emit = defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const { data: items }: ItemResponse = await useFetch(
|
||||
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/items.json'
|
||||
const { data: items } = useFetch<Array<{ id: number; iconPath: string }>>(
|
||||
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/items.json',
|
||||
{
|
||||
lazy: true, // Don't block rendering
|
||||
server: false // Client-side only
|
||||
}
|
||||
)
|
||||
const itemMap = reactive(new Map())
|
||||
for (const item of items.value) {
|
||||
|
||||
// Create item map reactively
|
||||
const itemMap = reactive(new Map<number, { id: number; iconPath: string }>())
|
||||
watch(
|
||||
items,
|
||||
newItems => {
|
||||
if (newItems) {
|
||||
itemMap.clear()
|
||||
for (const item of newItems) {
|
||||
itemMap.set(item.id, item)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function getItemIconPath(itemId: number): string {
|
||||
const item = itemMap.get(itemId)
|
||||
return item ? CDRAGON_BASE + mapPath(item.iconPath) : ''
|
||||
}
|
||||
|
||||
const start: Ref<Element | null> = useTemplateRef('start')
|
||||
const arrows: Array<svgdomarrowsLinePath> = []
|
||||
|
||||
onMounted(() => {
|
||||
// Only refresh arrows and emit if start element is available
|
||||
if (start.value) {
|
||||
refreshArrows()
|
||||
emit('mount', start.value!)
|
||||
emit('mount', start.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUpdate(() => {
|
||||
@@ -35,8 +58,10 @@ onBeforeUpdate(() => {
|
||||
})
|
||||
|
||||
onUpdated(() => {
|
||||
if (start.value) {
|
||||
refreshArrows()
|
||||
emit('mount', start.value!)
|
||||
emit('mount', start.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -85,8 +110,10 @@ addEventListener('scroll', _ => {
|
||||
})
|
||||
|
||||
function handleSubtreeMount(end: Element) {
|
||||
drawArrow(start.value!, end)
|
||||
if (start.value) {
|
||||
drawArrow(start.value, end)
|
||||
refreshArrows()
|
||||
}
|
||||
emit('refresh')
|
||||
}
|
||||
function handleRefresh() {
|
||||
@@ -107,7 +134,7 @@ function handleRefresh() {
|
||||
width="64"
|
||||
height="64"
|
||||
:alt="tree.data.toString()"
|
||||
:src="CDRAGON_BASE + mapPath(itemMap.get(tree.data).iconPath)"
|
||||
:src="getItemIconPath(tree.data)"
|
||||
/>
|
||||
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
|
||||
{{ ((tree.count / parentCount!!) * 100).toFixed(0) }}%
|
||||
|
||||
@@ -11,7 +11,14 @@ const props = defineProps<{
|
||||
const ITEMS_API_URL = CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/items.json'
|
||||
|
||||
// State
|
||||
const { data: items, pending: loadingItems, error: itemsError } = await useFetch(ITEMS_API_URL)
|
||||
const {
|
||||
data: items,
|
||||
pending: loadingItems,
|
||||
error: itemsError
|
||||
} = useFetch(ITEMS_API_URL, {
|
||||
lazy: true, // Don't block rendering
|
||||
server: false // Client-side only
|
||||
})
|
||||
const itemMap = ref<Map<number, unknown>>(new Map())
|
||||
|
||||
// Initialize item map
|
||||
@@ -107,13 +114,23 @@ const _isLoading = computed(() => loadingItems.value || props.loading)
|
||||
style="margin-left: 5px; margin-right: 5px"
|
||||
>
|
||||
<NuxtImg
|
||||
v-if="item.data != null && item.data != undefined"
|
||||
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
|
||||
class="item-img"
|
||||
width="64"
|
||||
height="64"
|
||||
:alt="item.data.toString()"
|
||||
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
style="
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 10px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-on-surface);
|
||||
"
|
||||
></div>
|
||||
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
|
||||
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
|
||||
</h3>
|
||||
@@ -129,13 +146,23 @@ const _isLoading = computed(() => loadingItems.value || props.loading)
|
||||
style="margin-left: 5px; margin-right: 5px"
|
||||
>
|
||||
<NuxtImg
|
||||
v-if="item.data != null && item.data != undefined"
|
||||
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
|
||||
class="item-img"
|
||||
width="64"
|
||||
height="64"
|
||||
:alt="item.data.toString()"
|
||||
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
style="
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 10px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-on-surface);
|
||||
"
|
||||
></div>
|
||||
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
|
||||
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
|
||||
</h3>
|
||||
@@ -153,13 +180,23 @@ const _isLoading = computed(() => loadingItems.value || props.loading)
|
||||
style="margin-left: 5px; margin-right: 5px"
|
||||
>
|
||||
<NuxtImg
|
||||
v-if="item.data != null && item.data != undefined"
|
||||
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
|
||||
class="item-img"
|
||||
width="64"
|
||||
height="64"
|
||||
:alt="item.data.toString()"
|
||||
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
style="
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 10px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-on-surface);
|
||||
"
|
||||
></div>
|
||||
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
|
||||
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
|
||||
</h3>
|
||||
@@ -181,13 +218,23 @@ const _isLoading = computed(() => loadingItems.value || props.loading)
|
||||
style="margin-left: 5px; margin-right: 5px"
|
||||
>
|
||||
<NuxtImg
|
||||
v-if="item.data != null && item.data != undefined"
|
||||
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
|
||||
class="item-img"
|
||||
width="64"
|
||||
height="64"
|
||||
:alt="item.data.toString()"
|
||||
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
style="
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 10px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-on-surface);
|
||||
"
|
||||
></div>
|
||||
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
|
||||
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
|
||||
</h3>
|
||||
@@ -205,13 +252,23 @@ const _isLoading = computed(() => loadingItems.value || props.loading)
|
||||
style="margin-left: 5px; margin-right: 5px"
|
||||
>
|
||||
<NuxtImg
|
||||
v-if="item.data != null && item.data != undefined"
|
||||
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
|
||||
class="item-img"
|
||||
width="64"
|
||||
height="64"
|
||||
:alt="item.data.toString()"
|
||||
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
style="
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 10px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-on-surface);
|
||||
"
|
||||
></div>
|
||||
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
|
||||
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
|
||||
</h3>
|
||||
@@ -225,13 +282,23 @@ const _isLoading = computed(() => loadingItems.value || props.loading)
|
||||
style="margin-left: 5px; margin-right: 5px"
|
||||
>
|
||||
<NuxtImg
|
||||
v-if="item.data != null && item.data != undefined"
|
||||
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
|
||||
class="item-img"
|
||||
width="64"
|
||||
height="64"
|
||||
:alt="item.data.toString()"
|
||||
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
style="
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 10px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-on-surface);
|
||||
"
|
||||
></div>
|
||||
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
|
||||
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
|
||||
</h3>
|
||||
|
||||
@@ -1,20 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const route = useRoute()
|
||||
const championAlias = route.params.alias as string
|
||||
|
||||
const { data: championData }: { data: Ref<ChampionData> } = await useFetch(
|
||||
'/api/champion/' + championAlias.toLowerCase()
|
||||
)
|
||||
const championId = championData.value.id
|
||||
// State management
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const laneState = ref(0)
|
||||
const state = ref('runes')
|
||||
|
||||
// Prefetch home page for faster navigation
|
||||
useHead({
|
||||
link: [{ rel: 'prefetch', href: '/' }]
|
||||
// Use useAsyncData with client-side fetching for faster initial page load
|
||||
const {
|
||||
data: championData,
|
||||
pending,
|
||||
error: fetchError,
|
||||
refresh
|
||||
} = useAsyncData<ChampionData>(
|
||||
'champion-' + championAlias.toLowerCase(),
|
||||
() => $fetch('/api/champion/' + championAlias.toLowerCase()),
|
||||
{
|
||||
server: false, // Disable server-side fetching for faster initial load
|
||||
lazy: false // Fetch immediately on client
|
||||
}
|
||||
)
|
||||
|
||||
// Handle errors from useAsyncData
|
||||
watchEffect(() => {
|
||||
if (fetchError.value) {
|
||||
console.error('Error fetching champion data:', fetchError.value)
|
||||
error.value =
|
||||
fetchError.value instanceof Error
|
||||
? fetchError.value.message
|
||||
: typeof fetchError.value === 'string'
|
||||
? fetchError.value
|
||||
: 'Unknown error occurred'
|
||||
}
|
||||
})
|
||||
|
||||
// Computed properties
|
||||
const championId = computed(() => championData.value?.id || 0)
|
||||
const lane = computed(() => championData.value?.lanes?.[laneState.value] || null)
|
||||
|
||||
// Update loading state with proper error handling
|
||||
isLoading.value = pending.value
|
||||
|
||||
// Watch for data changes to update loading state
|
||||
watch(
|
||||
[championData, fetchError, pending],
|
||||
([newData, newError, newPending]) => {
|
||||
if (newError) {
|
||||
error.value =
|
||||
newError instanceof Error
|
||||
? newError.message
|
||||
: typeof newError === 'string'
|
||||
? newError
|
||||
: 'Unknown error occurred'
|
||||
isLoading.value = false
|
||||
} else if (newData) {
|
||||
isLoading.value = false
|
||||
} else {
|
||||
isLoading.value = newPending
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Add timeout to prevent infinite loading
|
||||
const loadingTimeout = setTimeout(() => {
|
||||
if (isLoading.value && !championData.value && !error.value) {
|
||||
console.warn('Champion data loading timed out')
|
||||
error.value = 'Loading took too long. Please try again.'
|
||||
isLoading.value = false
|
||||
}
|
||||
}, 5000) // 5 second timeout
|
||||
|
||||
// Clean up timeout when component unmounts
|
||||
onUnmounted(() => {
|
||||
clearTimeout(loadingTimeout)
|
||||
})
|
||||
|
||||
// Set up SEO after data is loaded - only on server side
|
||||
if (import.meta.server) {
|
||||
watchEffect(() => {
|
||||
if (championData.value) {
|
||||
defineOgImageComponent('Champion', {
|
||||
title: championData.value.name,
|
||||
id: championId,
|
||||
id: championId.value,
|
||||
winrate: championData.value.winrate,
|
||||
pickrate: championData.value.pickrate,
|
||||
gameCount: championData.value.gameCount
|
||||
@@ -23,19 +95,55 @@ useSeoMeta({
|
||||
title: championData.value.name,
|
||||
description: 'Build path and runes for ' + championData.value.name + ' on BuildPath'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Prefetch home page for faster navigation
|
||||
useHead({
|
||||
link: [{ rel: 'prefetch', href: '/' }]
|
||||
})
|
||||
|
||||
const laneState = ref(0)
|
||||
const state = ref('runes')
|
||||
const lane = ref(championData.value.lanes[laneState.value])
|
||||
function updateState(newState: string, newLane: number) {
|
||||
state.value = newState
|
||||
laneState.value = newLane
|
||||
lane.value = championData.value.lanes[laneState.value]
|
||||
}
|
||||
|
||||
// Function to retry fetching data
|
||||
function fetchChampionData() {
|
||||
error.value = null
|
||||
isLoading.value = true
|
||||
refresh().catch(err => {
|
||||
console.error('Error refreshing champion data:', err)
|
||||
error.value = err instanceof Error ? err.message : 'Failed to refresh champion data'
|
||||
isLoading.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="champion-root">
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<div class="loading-spinner" />
|
||||
<p>Loading champion data...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<Logo style="margin: auto; margin-top: 64px; margin-bottom: 64px" />
|
||||
<div style="margin: auto; width: fit-content; margin-top: 64px">
|
||||
<h1>Error Loading Champion</h1>
|
||||
<h2>Sorry, we couldn't load this champion's data</h2>
|
||||
<div style="margin-top: 64px">
|
||||
<h3 v-if="error">Error: {{ error }}</h3>
|
||||
<button class="retry-button" @click="fetchChampionData">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success state -->
|
||||
<div v-else-if="championData" class="champion-success">
|
||||
<Head>
|
||||
<Title>{{ championData.name }}</Title>
|
||||
</Head>
|
||||
@@ -49,28 +157,34 @@ function updateState(newState: string, newLane: number) {
|
||||
|
||||
<div id="champion-content">
|
||||
<ChampionTitle
|
||||
v-if="championData.gameCount > 0"
|
||||
v-if="championData.gameCount > 0 && lane"
|
||||
id="champion-title"
|
||||
:champion-id="championId"
|
||||
:winrate="lane.winrate"
|
||||
:pickrate="lane.pickrate"
|
||||
:game-count="lane.count"
|
||||
:winrate="lane.winrate || 0"
|
||||
:pickrate="lane.pickrate || 0"
|
||||
:game-count="lane.count || 0"
|
||||
/>
|
||||
<RuneSelector
|
||||
v-if="state == 'runes' && championData.gameCount > 0"
|
||||
<ClientOnly>
|
||||
<LazyRuneSelector
|
||||
v-if="state == 'runes' && championData.gameCount > 0 && lane?.runes"
|
||||
style="margin: auto; margin-top: 40px"
|
||||
:runes="lane.runes!!"
|
||||
:runes="lane.runes"
|
||||
/>
|
||||
<ItemViewer
|
||||
v-if="state == 'items' && championData.gameCount > 0"
|
||||
</ClientOnly>
|
||||
<ClientOnly>
|
||||
<LazyItemViewer
|
||||
v-if="state == 'items' && championData.gameCount > 0 && lane?.builds"
|
||||
style="margin: auto; margin-top: 40px"
|
||||
:builds="lane.builds!!"
|
||||
:builds="lane.builds"
|
||||
/>
|
||||
<ItemTree
|
||||
v-if="state == 'alternatives' && championData.gameCount > 0"
|
||||
</ClientOnly>
|
||||
<ClientOnly>
|
||||
<LazyItemTree
|
||||
v-if="state == 'alternatives' && championData.gameCount > 0 && lane?.builds?.tree"
|
||||
style="margin: auto; margin-top: 40px; width: fit-content"
|
||||
:tree="lane.builds!!.tree"
|
||||
:tree="lane.builds.tree"
|
||||
/>
|
||||
</ClientOnly>
|
||||
<h2
|
||||
v-if="championData.gameCount == 0"
|
||||
style="margin: auto; margin-top: 20px; width: fit-content"
|
||||
@@ -80,6 +194,7 @@ function updateState(newState: string, newLane: number) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@@ -96,6 +211,83 @@ function updateState(newState: string, newLane: number) {
|
||||
margin-left: 39px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Loading state styles */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 6px solid var(--color-surface);
|
||||
border-top: 6px solid var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-state p {
|
||||
margin: 0;
|
||||
color: var(--color-on-surface);
|
||||
font-size: 1.2rem;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Error state styles */
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 64px 20px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
background-color: #4caf50;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
margin-top: 20px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 650px) {
|
||||
#champion-content {
|
||||
margin: auto;
|
||||
|
||||
Reference in New Issue
Block a user