frontend: refactor of the new build viewer

This commit is contained in:
2026-02-28 13:18:02 +01:00
parent 3e9a8295b2
commit 20ccb20738
6 changed files with 581 additions and 372 deletions

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
const props = defineProps<{
keystoneId: number
itemId: number
keystore: Map<number, Perk>
itemMap: Map<number, Item>
pickrate: number
}>()
</script>
<template>
<div class="build-variant-selector">
<div :class="['build-variant-card', { selected: true }]">
<div class="variant-content">
<!-- Keystone -->
<NuxtImg
v-if="keystoneId && props.keystore.get(keystoneId)"
class="variant-keystone"
:src="CDRAGON_BASE + mapPath(props.keystore.get(keystoneId)!.iconPath)"
/>
<!-- First core item -->
<NuxtImg
v-if="itemMap.get(itemId)"
class="variant-item"
:src="CDRAGON_BASE + mapPath(itemMap.get(itemId)!.iconPath)"
/>
<div v-else class="variant-item-placeholder" />
</div>
<span class="variant-pickrate">{{ (pickrate * 100).toFixed(1) }}%</span>
</div>
</div>
</template>
<style scoped>
.build-variant-selector {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 20px;
padding: 0 12px;
overflow-x: auto;
scrollbar-width: none;
}
.build-variant-selector::-webkit-scrollbar {
display: none;
}
.build-variant-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 14px;
background: var(--color-surface-darker);
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
min-width: 90px;
}
.build-variant-card:hover {
border-color: var(--color-on-surface);
}
.build-variant-card.selected {
border-color: #4a9eff;
background: linear-gradient(135deg, rgba(74, 158, 255, 0.15), rgba(74, 158, 255, 0.05));
}
.variant-content {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.variant-keystone {
width: 28px;
height: 28px;
border-radius: 50%;
}
.variant-item {
width: 28px;
height: 28px;
border-radius: 4px;
border: 1px solid rgba(183, 184, 225, 0.3);
}
.variant-item-placeholder {
width: 28px;
height: 28px;
background: var(--color-surface);
border-radius: 4px;
border: 1px solid rgba(183, 184, 225, 0.3);
}
.variant-pickrate {
font-size: 0.75rem;
color: var(--color-on-surface);
opacity: 0.8;
}
/* Responsive: Mobile */
@media only screen and (max-width: 900px) {
.build-variant-card {
min-width: 70px;
padding: 6px 10px;
}
.variant-keystone,
.variant-item {
width: 24px;
height: 24px;
}
.variant-item-placeholder {
width: 24px;
height: 24px;
}
.variant-pickrate {
font-size: 0.65rem;
}
}
@media only screen and (max-width: 400px) {
.build-variant-selector {
gap: 6px;
}
.build-variant-card {
min-width: 60px;
padding: 5px 8px;
}
.variant-keystone,
.variant-item {
width: 20px;
height: 20px;
}
.variant-item-placeholder {
width: 20px;
height: 20px;
}
}
</style>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
interface RuneBuild {
count: number
primaryStyle: number
secondaryStyle: number
selections: Array<number>
pickrate: number
}
interface PerkStyle {
id: number
iconPath: string
}
const props = defineProps<{
runes: Array<RuneBuild>
primaryStyles: Array<PerkStyle>
secondaryStyles: Array<PerkStyle>
keystoneIds: Array<number>
perks: Map<number, Perk>
selectedIndex: number
}>()
const emit = defineEmits<{
select: [index: number]
}>()
</script>
<template>
<div class="compact-rune-selector">
<div
v-for="(rune, index) in props.runes"
:key="index"
:class="['compact-rune-option', { active: index === props.selectedIndex }]"
@click="emit('select', index)"
>
<div class="compact-rune-content">
<NuxtImg
v-if="primaryStyles[index]"
class="compact-rune-img"
:src="CDRAGON_BASE + mapPath(primaryStyles[index].iconPath)"
/>
<NuxtImg
v-if="keystoneIds[index] && props.perks.get(keystoneIds[index])"
class="compact-rune-img"
:src="CDRAGON_BASE + mapPath(props.perks.get(keystoneIds[index])!.iconPath)"
/>
<NuxtImg
v-if="secondaryStyles[index]"
class="compact-rune-img"
:src="CDRAGON_BASE + mapPath(secondaryStyles[index].iconPath)"
/>
</div>
<span class="compact-rune-pickrate">{{ (rune.pickrate * 100).toFixed(1) }}%</span>
</div>
</div>
</template>
<style scoped>
.compact-rune-selector {
display: flex;
justify-content: center;
gap: 6px;
margin-top: -45px;
overflow-x: auto;
scrollbar-width: none;
}
.compact-rune-selector::-webkit-scrollbar {
display: none;
}
.compact-rune-option {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 10px;
background: transparent;
border: 2px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
min-width: 60px;
}
.compact-rune-option:hover {
border-color: var(--color-on-surface);
opacity: 0.8;
}
.compact-rune-option.active {
border-color: #4a9eff;
background: linear-gradient(135deg, rgba(74, 158, 255, 0.15), rgba(74, 158, 255, 0.05));
}
.compact-rune-content {
display: flex;
align-items: center;
gap: 3px;
margin-bottom: 3px;
}
.compact-rune-img {
width: 20px;
height: 20px;
border-radius: 3px;
}
.compact-rune-pickrate {
font-size: 0.65rem;
color: var(--color-on-surface);
opacity: 0.7;
}
.compact-rune-option.active .compact-rune-pickrate {
opacity: 0.9;
color: #4a9eff;
}
</style>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
interface ItemData {
data: number
count: number
}
const props = defineProps<{
label: string
items: Array<ItemData>
itemMap: Map<number, Item>
totalCount: number
maxItems?: number
}>()
const maxItems = computed(() => props.maxItems ?? props.items.length)
</script>
<template>
<div class="item-row">
<span class="item-row-label">{{ label }}</span>
<div class="item-row-content">
<div v-for="item in items.slice(0, maxItems)" :key="item.data" class="item-cell">
<NuxtImg
v-if="itemMap.get(item.data)"
class="item-img"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data)!.iconPath)"
/>
<div v-else class="item-placeholder" />
<span class="item-pickrate">{{ ((item.count / totalCount) * 100).toFixed(0) }}%</span>
</div>
</div>
</div>
</template>
<style scoped>
.item-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.item-row-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-on-surface);
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.item-row-content {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.item-cell {
display: flex;
flex-direction: column;
align-items: center;
}
.item-img {
width: 48px;
height: 48px;
border-radius: 4px;
border: 1px solid var(--color-on-surface);
}
.item-placeholder {
width: 48px;
height: 48px;
background: var(--color-surface);
border-radius: 4px;
border: 1px solid var(--color-on-surface);
}
.item-pickrate {
font-size: 0.65rem;
color: var(--color-on-surface);
opacity: 0.6;
margin-top: 1px;
}
/* Responsive: Mobile */
@media only screen and (max-width: 900px) {
.item-img {
width: 40px;
height: 40px;
}
.item-placeholder {
width: 40px;
height: 40px;
}
}
</style>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
interface SummonerSpellData {
id: number
count: number
pickrate: number
}
const props = defineProps<{
spells: Array<SummonerSpellData>
summonerSpellMap: Map<number, SummonerSpell>
}>()
</script>
<template>
<div class="summoner-spells-section">
<h3 class="section-title">Summoner Spells</h3>
<div class="summoner-spells-row">
<div v-for="(spell, i) in props.spells.slice(0, 2)" :key="i" class="summoner-spell-item">
<NuxtImg
v-if="summonerSpellMap.get(spell.id)"
class="summoner-spell-img"
:src="CDRAGON_BASE + mapPath(summonerSpellMap.get(spell.id)!.iconPath)"
/>
<div v-else class="summoner-spell-placeholder" />
<span class="spell-pickrate">{{ (spell.pickrate * 100).toFixed(0) }}%</span>
</div>
</div>
</div>
</template>
<style scoped>
.summoner-spells-section {
display: flex;
flex-direction: column;
}
.section-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 10px;
color: #4a9eff;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.summoner-spells-row {
display: flex;
gap: 8px;
}
.summoner-spell-item {
display: flex;
align-items: center;
gap: 6px;
}
.summoner-spell-img {
width: 36px;
height: 36px;
border-radius: 4px;
border: 1px solid var(--color-on-surface);
}
.summoner-spell-placeholder {
width: 36px;
height: 36px;
background: var(--color-surface);
border-radius: 4px;
border: 1px solid var(--color-on-surface);
}
.spell-pickrate {
font-size: 0.7rem;
color: var(--color-on-surface);
opacity: 0.7;
}
/* Responsive: Mobile */
@media only screen and (max-width: 900px) {
.section-title {
font-size: 0.9rem;
}
.summoner-spell-img,
.summoner-spell-placeholder {
width: 36px;
height: 36px;
}
}
</style>

View File

@@ -1,5 +1,9 @@
<script setup lang="ts">
import { isEmpty, deepClone } from '~/utils/helpers'
import BuildVariantSelector from '~/components/build/BuildVariantSelector.vue'
import SummonerSpells from '~/components/build/SummonerSpells.vue'
import CompactRuneSelector from '~/components/build/CompactRuneSelector.vue'
import ItemRow from '~/components/build/ItemRow.vue'
const props = defineProps<{
runes: Array<{
@@ -136,6 +140,35 @@ const firstCoreItems = computed(() => {
return result
})
// Get the highest pickrate rune build
const highestPickrateBuildIndex = computed(() => {
if (props.runes.length === 0) return 0
let maxIndex = 0
let maxPickrate = props.runes[0].pickrate
for (let i = 1; i < props.runes.length; i++) {
if (props.runes[i].pickrate > maxPickrate) {
maxPickrate = props.runes[i].pickrate
maxIndex = i
}
}
return maxIndex
})
// State for compact rune selector
const selectedRuneIndex = ref(0)
watch(
() => currentlySelectedBuild,
() => {
selectedRuneIndex.value = 0
}
)
function selectRune(index: number) {
selectedRuneIndex.value = index
currentlySelectedBuild.value = index
}
// Rune styles
const primaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
const secondaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
@@ -179,64 +212,25 @@ watch(
)
refreshStylesKeystones()
function selectBuild(index: number) {
currentlySelectedBuild.value = index
}
</script>
<template>
<div class="build-viewer">
<!-- Global Build Variant Selector -->
<div class="build-variant-selector">
<div
v-for="(_, i) in runes"
:key="i"
:class="['build-variant-card', { selected: i === currentlySelectedBuild }]"
@click="selectBuild(i)"
>
<div class="variant-content">
<!-- Keystone -->
<NuxtImg
v-if="keystoneIds[i] && perks.get(keystoneIds[i])"
class="variant-keystone"
:src="CDRAGON_BASE + mapPath(perks.get(keystoneIds[i]).iconPath)"
<!-- Global Build Variant Selector - Single variant with highest pickrate -->
<BuildVariantSelector
:keystone-id="keystoneIds[highestPickrateBuildIndex]"
:item-id="firstCoreItems[highestPickrateBuildIndex]"
:keystore="perks"
:item-map="itemMap"
:pickrate="1"
/>
<!-- First core item -->
<NuxtImg
v-if="itemMap.get(firstCoreItems[i])"
class="variant-item"
:src="CDRAGON_BASE + mapPath(itemMap.get(firstCoreItems[i])!.iconPath)"
/>
<div v-else class="variant-item-placeholder" />
</div>
<span class="variant-pickrate">{{ (runes[i].pickrate * 100).toFixed(1) }}%</span>
</div>
</div>
<!-- Main Build Content -->
<div class="build-content">
<!-- Left Column: Summoner Spells + Runes -->
<div class="build-left-column">
<!-- Summoner Spells -->
<div class="summoner-spells-section">
<h3 class="section-title">Summoner Spells</h3>
<div class="summoner-spells-row">
<div
v-for="(spell, i) in mockSummonerSpells.slice(0, 2)"
:key="i"
class="summoner-spell-item"
>
<NuxtImg
v-if="summonerSpellMap.get(spell.id)"
class="summoner-spell-img"
:src="CDRAGON_BASE + mapPath(summonerSpellMap.get(spell.id)!.iconPath)"
/>
<div v-else class="summoner-spell-placeholder" />
<span class="spell-pickrate">{{ (spell.pickrate * 100).toFixed(0) }}%</span>
</div>
</div>
</div>
<SummonerSpells :spells="mockSummonerSpells" :summoner-spell-map="summonerSpellMap" />
<!-- Rune Page -->
<div class="rune-section">
@@ -249,6 +243,17 @@ function selectBuild(index: number) {
:selection-ids="runes[currentlySelectedBuild].selections"
/>
</div>
<!-- Compact Rune Selector -->
<CompactRuneSelector
:runes="runes"
:primary-styles="primaryStyles"
:secondary-styles="secondaryStyles"
:keystone-ids="keystoneIds"
:perks="perks"
:selected-index="currentlySelectedBuild"
@select="selectRune"
/>
</div>
</div>
@@ -259,60 +264,31 @@ function selectBuild(index: number) {
<!-- Start/Support + Boots Container -->
<div class="item-row-group">
<!-- Start Items -->
<div v-if="!builds.suppItems" class="item-row">
<span class="item-row-label">Start</span>
<div class="item-row-content">
<div v-for="item in builds.start" :key="item.data" class="item-cell">
<NuxtImg
v-if="itemMap.get(item.data)"
class="item-img"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data)!.iconPath)"
<ItemRow
v-if="!builds.suppItems"
label="Start"
:items="builds.start"
:item-map="itemMap"
:total-count="builds.tree.count"
/>
<div v-else class="item-placeholder" />
<span class="item-pickrate"
>{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%</span
>
</div>
</div>
</div>
<!-- Support Items -->
<div v-if="builds.suppItems" class="item-row">
<span class="item-row-label">Support</span>
<div class="item-row-content">
<div v-for="item in builds.suppItems" :key="item.data" class="item-cell">
<NuxtImg
v-if="itemMap.get(item.data)"
class="item-img"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data)!.iconPath)"
<ItemRow
v-if="builds.suppItems"
label="Support"
:items="builds.suppItems"
:item-map="itemMap"
:total-count="builds.tree.count"
/>
<div v-else class="item-placeholder" />
<span class="item-pickrate"
>{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%</span
>
</div>
</div>
</div>
<!-- Boots (regular or rush) -->
<div class="item-row">
<span class="item-row-label">{{
builds.bootsFirst > 0.5 ? 'Boots Rush' : 'Boots'
}}</span>
<div class="item-row-content">
<div v-for="item in builds.boots.slice(0, 2)" :key="item.data" class="item-cell">
<NuxtImg
v-if="itemMap.get(item.data)"
class="item-img"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data)!.iconPath)"
<ItemRow
:label="builds.bootsFirst > 0.5 ? 'Boots Rush' : 'Boots'"
:items="builds.boots.slice(0, 2)"
:item-map="itemMap"
:total-count="builds.tree.count"
:max-items="2"
/>
<div v-else class="item-placeholder" />
<span class="item-pickrate"
>{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%</span
>
</div>
</div>
</div>
</div>
<!-- Core Items Tree -->
@@ -322,22 +298,13 @@ function selectBuild(index: number) {
</div>
<!-- Late Game -->
<div class="item-row">
<span class="item-row-label">Late Game</span>
<div class="item-row-content">
<div v-for="item in builds.lateGame.slice(0, 6)" :key="item.data" class="item-cell">
<NuxtImg
v-if="itemMap.get(item.data)"
class="item-img"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data)!.iconPath)"
<ItemRow
label="Late Game"
:items="builds.lateGame.slice(0, 6)"
:item-map="itemMap"
:total-count="builds.tree.count"
:max-items="6"
/>
<div v-else class="item-placeholder" />
<span class="item-pickrate"
>{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%</span
>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -353,77 +320,6 @@ function selectBuild(index: number) {
align-items: center;
}
/* Build Variant Selector - Global */
.build-variant-selector {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 20px;
padding: 0 12px;
overflow-x: auto;
scrollbar-width: none;
}
.build-variant-selector::-webkit-scrollbar {
display: none;
}
.build-variant-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 14px;
background: var(--color-surface-darker);
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
min-width: 90px;
}
.build-variant-card:hover {
border-color: var(--color-on-surface);
}
.build-variant-card.selected {
border-color: #4a9eff;
background: linear-gradient(135deg, rgba(74, 158, 255, 0.15), rgba(74, 158, 255, 0.05));
}
.variant-content {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.variant-keystone {
width: 28px;
height: 28px;
border-radius: 50%;
}
.variant-item {
width: 28px;
height: 28px;
border-radius: 4px;
border: 1px solid rgba(183, 184, 225, 0.3);
}
.variant-item-placeholder {
width: 28px;
height: 28px;
background: var(--color-surface);
border-radius: 4px;
border: 1px solid rgba(183, 184, 225, 0.3);
}
.variant-pickrate {
font-size: 0.75rem;
color: var(--color-on-surface);
opacity: 0.8;
}
/* Main Build Content */
.build-content {
display: flex;
@@ -449,30 +345,6 @@ function selectBuild(index: number) {
letter-spacing: 0.5px;
}
/* Rune page selector dots */
.rune-page-selector {
display: flex;
gap: 6px;
}
.rune-page-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-surface);
cursor: pointer;
transition: all 0.2s ease;
}
.rune-page-dot:hover {
background: var(--color-on-surface);
opacity: 0.6;
}
.rune-page-dot.active {
background: #4a9eff;
}
/* Left Column: Runes + Summoner Spells */
.build-left-column {
display: flex;
@@ -486,46 +358,9 @@ function selectBuild(index: number) {
}
.rune-page-wrapper {
transform: scale(0.85);
transform-origin: top left;
}
/* Summoner Spells - Compact */
.summoner-spells-section {
display: flex;
flex-direction: column;
}
.summoner-spells-row {
display: flex;
gap: 8px;
}
.summoner-spell-item {
display: flex;
align-items: center;
gap: 6px;
}
.summoner-spell-img {
width: 36px;
height: 36px;
border-radius: 4px;
border: 1px solid var(--color-on-surface);
}
.summoner-spell-placeholder {
width: 36px;
height: 36px;
background: var(--color-surface);
border-radius: 4px;
border: 1px solid var(--color-on-surface);
}
.spell-pickrate {
font-size: 0.7rem;
color: var(--color-on-surface);
opacity: 0.7;
margin-top: -45px;
transform: scale(0.7);
transform-origin: center;
}
/* Right Column: Items */
@@ -557,48 +392,6 @@ function selectBuild(index: number) {
letter-spacing: 0.5px;
}
.item-row-content {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.item-cell {
display: flex;
flex-direction: column;
align-items: center;
}
.item-img {
width: 48px;
height: 48px;
border-radius: 4px;
border: 1px solid var(--color-on-surface);
}
.item-placeholder {
width: 48px;
height: 48px;
background: var(--color-surface);
border-radius: 4px;
border: 1px solid var(--color-on-surface);
}
.item-pickrate {
font-size: 0.65rem;
color: var(--color-on-surface);
opacity: 0.6;
margin-top: 1px;
}
.item-separator {
width: 1px;
height: 48px;
background: var(--color-on-surface);
opacity: 0.3;
margin: 0 8px;
}
/* Responsive: Mobile */
@media only screen and (max-width: 900px) {
.build-content {
@@ -617,26 +410,6 @@ function selectBuild(index: number) {
align-items: center;
}
.build-variant-card {
min-width: 70px;
padding: 6px 10px;
}
.variant-keystone,
.variant-item {
width: 24px;
height: 24px;
}
.variant-item-placeholder {
width: 24px;
height: 24px;
}
.variant-pickrate {
font-size: 0.65rem;
}
.section-title {
font-size: 0.9rem;
}
@@ -650,43 +423,5 @@ function selectBuild(index: number) {
transform: scale(1);
transform-origin: center center;
}
.item-img {
width: 40px;
height: 40px;
}
.item-placeholder {
width: 40px;
height: 40px;
}
.summoner-spell-img,
.summoner-spell-placeholder {
width: 36px;
height: 36px;
}
}
@media only screen and (max-width: 400px) {
.build-variant-selector {
gap: 6px;
}
.build-variant-card {
min-width: 60px;
padding: 5px 8px;
}
.variant-keystone,
.variant-item {
width: 20px;
height: 20px;
}
.variant-item-placeholder {
width: 20px;
height: 20px;
}
}
</style>

View File

@@ -12,10 +12,13 @@ const emit = defineEmits<{
parentReady: []
}>()
const { data: items, pending: itemsLoading } = useFetch<Array<{ id: number; iconPath: string }>>('/api/cdragon/items', {
const { data: items, pending: itemsLoading } = useFetch<Array<{ id: number; iconPath: string }>>(
'/api/cdragon/items',
{
lazy: true, // Don't block rendering
server: false // Client-side only
})
}
)
// Track image loading state
const imagesLoaded = ref(false)
@@ -47,18 +50,26 @@ const pendingChildMounts: Array<Element> = []
// Function to wait for an image to load
function waitForImageLoad(imgElement: HTMLImageElement): Promise<void> {
return new Promise((resolve) => {
return new Promise(resolve => {
if (imgElement.complete) {
requestAnimationFrame(() => resolve())
return
}
imgElement.addEventListener('load', () => {
imgElement.addEventListener(
'load',
() => {
requestAnimationFrame(() => resolve())
}, { once: true })
imgElement.addEventListener('error', () => {
},
{ once: true }
)
imgElement.addEventListener(
'error',
() => {
requestAnimationFrame(() => resolve())
}, { once: true })
},
{ once: true }
)
})
}
@@ -67,11 +78,11 @@ onMounted(async () => {
await nextTick()
// Wait for items to be loaded
await new Promise<void>((resolve) => {
await new Promise<void>(resolve => {
if (!itemsLoading.value) {
resolve()
} else {
const unwatch = watch(itemsLoading, (loading) => {
const unwatch = watch(itemsLoading, loading => {
if (!loading) {
unwatch()
resolve()