Champion page improvements
All checks were successful
pipeline / lint-and-format (push) Successful in 4m50s
pipeline / build-and-push-images (push) Successful in 1m37s

This commit is contained in:
2026-01-23 23:25:17 +01:00
parent 82bb01c039
commit 7a34c16087
3 changed files with 362 additions and 76 deletions

View File

@@ -11,20 +11,43 @@ const emit = defineEmits<{
refresh: [] refresh: []
}>() }>()
const { data: items }: ItemResponse = await useFetch( const { data: items } = useFetch<Array<{ id: number; iconPath: string }>>(
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/items.json' 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
itemMap.set(item.id, item) 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 start: Ref<Element | null> = useTemplateRef('start')
const arrows: Array<svgdomarrowsLinePath> = [] const arrows: Array<svgdomarrowsLinePath> = []
onMounted(() => { onMounted(() => {
refreshArrows() // Only refresh arrows and emit if start element is available
emit('mount', start.value!) if (start.value) {
refreshArrows()
emit('mount', start.value)
}
}) })
onBeforeUpdate(() => { onBeforeUpdate(() => {
@@ -35,8 +58,10 @@ onBeforeUpdate(() => {
}) })
onUpdated(() => { onUpdated(() => {
refreshArrows() if (start.value) {
emit('mount', start.value!) refreshArrows()
emit('mount', start.value)
}
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -85,8 +110,10 @@ addEventListener('scroll', _ => {
}) })
function handleSubtreeMount(end: Element) { function handleSubtreeMount(end: Element) {
drawArrow(start.value!, end) if (start.value) {
refreshArrows() drawArrow(start.value, end)
refreshArrows()
}
emit('refresh') emit('refresh')
} }
function handleRefresh() { function handleRefresh() {
@@ -107,7 +134,7 @@ function handleRefresh() {
width="64" width="64"
height="64" height="64"
:alt="tree.data.toString()" :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"> <h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((tree.count / parentCount!!) * 100).toFixed(0) }}% {{ ((tree.count / parentCount!!) * 100).toFixed(0) }}%

View File

@@ -11,7 +11,14 @@ const props = defineProps<{
const ITEMS_API_URL = CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/items.json' const ITEMS_API_URL = CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/items.json'
// State // 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()) const itemMap = ref<Map<number, unknown>>(new Map())
// Initialize item map // Initialize item map
@@ -107,13 +114,23 @@ const _isLoading = computed(() => loadingItems.value || props.loading)
style="margin-left: 5px; margin-right: 5px" style="margin-left: 5px; margin-right: 5px"
> >
<NuxtImg <NuxtImg
v-if="item.data != null && item.data != undefined" v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img" class="item-img"
width="64" width="64"
height="64" height="64"
:alt="item.data.toString()" :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)" :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"> <h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}% {{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3> </h3>
@@ -129,13 +146,23 @@ const _isLoading = computed(() => loadingItems.value || props.loading)
style="margin-left: 5px; margin-right: 5px" style="margin-left: 5px; margin-right: 5px"
> >
<NuxtImg <NuxtImg
v-if="item.data != null && item.data != undefined" v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img" class="item-img"
width="64" width="64"
height="64" height="64"
:alt="item.data.toString()" :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)" :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"> <h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}% {{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3> </h3>
@@ -153,13 +180,23 @@ const _isLoading = computed(() => loadingItems.value || props.loading)
style="margin-left: 5px; margin-right: 5px" style="margin-left: 5px; margin-right: 5px"
> >
<NuxtImg <NuxtImg
v-if="item.data != null && item.data != undefined" v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img" class="item-img"
width="64" width="64"
height="64" height="64"
:alt="item.data.toString()" :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)" :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"> <h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}% {{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3> </h3>
@@ -181,13 +218,23 @@ const _isLoading = computed(() => loadingItems.value || props.loading)
style="margin-left: 5px; margin-right: 5px" style="margin-left: 5px; margin-right: 5px"
> >
<NuxtImg <NuxtImg
v-if="item.data != null && item.data != undefined" v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img" class="item-img"
width="64" width="64"
height="64" height="64"
:alt="item.data.toString()" :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)" :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"> <h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}% {{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3> </h3>
@@ -205,13 +252,23 @@ const _isLoading = computed(() => loadingItems.value || props.loading)
style="margin-left: 5px; margin-right: 5px" style="margin-left: 5px; margin-right: 5px"
> >
<NuxtImg <NuxtImg
v-if="item.data != null && item.data != undefined" v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img" class="item-img"
width="64" width="64"
height="64" height="64"
:alt="item.data.toString()" :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)" :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"> <h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}% {{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3> </h3>
@@ -225,13 +282,23 @@ const _isLoading = computed(() => loadingItems.value || props.loading)
style="margin-left: 5px; margin-right: 5px" style="margin-left: 5px; margin-right: 5px"
> >
<NuxtImg <NuxtImg
v-if="item.data != null && item.data != undefined" v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img" class="item-img"
width="64" width="64"
height="64" height="64"
:alt="item.data.toString()" :alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)" :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"> <h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}% {{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3> </h3>

View File

@@ -1,82 +1,197 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'
const route = useRoute() const route = useRoute()
const championAlias = route.params.alias as string const championAlias = route.params.alias as string
const { data: championData }: { data: Ref<ChampionData> } = await useFetch( // State management
'/api/champion/' + championAlias.toLowerCase() const isLoading = ref(true)
const error = ref<string | null>(null)
const laneState = ref(0)
const state = ref('runes')
// 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
}
) )
const championId = championData.value.id
// 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.value,
winrate: championData.value.winrate,
pickrate: championData.value.pickrate,
gameCount: championData.value.gameCount
})
useSeoMeta({
title: championData.value.name,
description: 'Build path and runes for ' + championData.value.name + ' on BuildPath'
})
}
})
}
// Prefetch home page for faster navigation // Prefetch home page for faster navigation
useHead({ useHead({
link: [{ rel: 'prefetch', href: '/' }] link: [{ rel: 'prefetch', href: '/' }]
}) })
defineOgImageComponent('Champion', {
title: championData.value.name,
id: championId,
winrate: championData.value.winrate,
pickrate: championData.value.pickrate,
gameCount: championData.value.gameCount
})
useSeoMeta({
title: championData.value.name,
description: 'Build path and runes for ' + championData.value.name + ' on BuildPath'
})
const laneState = ref(0)
const state = ref('runes')
const lane = ref(championData.value.lanes[laneState.value])
function updateState(newState: string, newLane: number) { function updateState(newState: string, newLane: number) {
state.value = newState state.value = newState
laneState.value = newLane 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> </script>
<template> <template>
<div class="champion-root"> <div class="champion-root">
<Head> <!-- Loading state -->
<Title>{{ championData.name }}</Title> <div v-if="isLoading" class="loading-state">
</Head> <div class="loading-spinner" />
<p>Loading champion data...</p>
</div>
<div id="alias-content-wrapper"> <!-- Error state -->
<NavBar <div v-else-if="error" class="error-state">
:champion-name="championData.name" <Logo style="margin: auto; margin-top: 64px; margin-bottom: 64px" />
:champion-lanes="championData.lanes" <div style="margin: auto; width: fit-content; margin-top: 64px">
@state-change="updateState" <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>
<div id="champion-content"> <!-- Success state -->
<ChampionTitle <div v-else-if="championData" class="champion-success">
v-if="championData.gameCount > 0" <Head>
id="champion-title" <Title>{{ championData.name }}</Title>
:champion-id="championId" </Head>
:winrate="lane.winrate"
:pickrate="lane.pickrate" <div id="alias-content-wrapper">
:game-count="lane.count" <NavBar
:champion-name="championData.name"
:champion-lanes="championData.lanes"
@state-change="updateState"
/> />
<RuneSelector
v-if="state == 'runes' && championData.gameCount > 0" <div id="champion-content">
style="margin: auto; margin-top: 40px" <ChampionTitle
:runes="lane.runes!!" v-if="championData.gameCount > 0 && lane"
/> id="champion-title"
<ItemViewer :champion-id="championId"
v-if="state == 'items' && championData.gameCount > 0" :winrate="lane.winrate || 0"
style="margin: auto; margin-top: 40px" :pickrate="lane.pickrate || 0"
:builds="lane.builds!!" :game-count="lane.count || 0"
/> />
<ItemTree <ClientOnly>
v-if="state == 'alternatives' && championData.gameCount > 0" <LazyRuneSelector
style="margin: auto; margin-top: 40px; width: fit-content" v-if="state == 'runes' && championData.gameCount > 0 && lane?.runes"
:tree="lane.builds!!.tree" style="margin: auto; margin-top: 40px"
/> :runes="lane.runes"
<h2 />
v-if="championData.gameCount == 0" </ClientOnly>
style="margin: auto; margin-top: 20px; width: fit-content" <ClientOnly>
> <LazyItemViewer
Sorry, there is no data for this champion :( v-if="state == 'items' && championData.gameCount > 0 && lane?.builds"
</h2> style="margin: auto; margin-top: 40px"
:builds="lane.builds"
/>
</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"
/>
</ClientOnly>
<h2
v-if="championData.gameCount == 0"
style="margin: auto; margin-top: 20px; width: fit-content"
>
Sorry, there is no data for this champion :(
</h2>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -96,6 +211,83 @@ function updateState(newState: string, newLane: number) {
margin-left: 39px; margin-left: 39px;
width: 100%; 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) { @media only screen and (max-width: 650px) {
#champion-content { #champion-content {
margin: auto; margin: auto;