Files
buildpath/frontend/components/ChampionSelector.vue
Valentin Haudiquet 3d79d9e495
Some checks failed
pipeline / lint-and-format (push) Failing after 4m18s
pipeline / build-and-push-images (push) Has been skipped
Lint frontend
2026-01-21 23:39:03 +01:00

328 lines
7.2 KiB
Vue

<script setup lang="ts">
import { debounce, isEmpty } from '~/utils/helpers'
// Constants
const CDRAGON_CHAMPIONS_URL =
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
const CHAMPIONS_API_URL = '/api/champions'
// State
const {
data: championsData,
pending: loadingChampions,
error: championsError
} = useFetch(CDRAGON_CHAMPIONS_URL, {
key: 'champions-data',
lazy: false,
server: false // Disable server-side fetching to avoid hydration issues
})
const {
data: championsLanes,
pending: loadingLanes,
error: lanesError
} = useFetch(CHAMPIONS_API_URL, {
key: 'champions-lanes',
lazy: false,
server: false // Disable server-side fetching to avoid hydration issues
})
// Data processing
const champions = computed(() => {
if (!championsData.value || !Array.isArray(championsData.value)) return []
return championsData.value
.slice(1)
.filter((champion: ChampionSummary) => !champion.name.includes('Doom Bot'))
.sort((a: ChampionSummary, b: ChampionSummary) => a.name.localeCompare(b.name))
})
const lanesMap = computed(() => {
const map = new Map<string, LaneData[]>()
if (championsLanes.value) {
for (const champion of championsLanes.value as ChampionData[]) {
map.set(champion.alias.toLowerCase(), champion.lanes)
}
}
return map
})
// Filter state
const filteredChampions = ref<ChampionSummary[]>([])
const searchText = ref('')
const searchBar = useTemplateRef('searchBar')
// Lane filtering
function filterToLane(filter: number): string {
const laneMap = ['TOP', 'JUNGLE', 'MIDDLE', 'BOTTOM', 'UTILITY']
return laneMap[filter] || ''
}
function filterChampionsByLane(laneFilter: number): void {
if (laneFilter === -1) {
filteredChampions.value = [...champions.value]
return
}
const laneName = filterToLane(laneFilter)
filteredChampions.value = champions.value.filter((champion: ChampionSummary) => {
const championLanes = lanesMap.value.get(champion.alias.toLowerCase())
if (!championLanes) return false
return championLanes.some(lane => lane.data === laneName)
})
}
// Search functionality
const debouncedSearch = debounce((searchTerm: string) => {
if (isEmpty(searchTerm)) {
filteredChampions.value = [...champions.value]
} else {
filteredChampions.value = champions.value.filter((champion: ChampionSummary) =>
champion.name.toLowerCase().includes(searchTerm.toLowerCase())
)
}
}, 300)
// Watchers
watch(searchBar, (_newS, _oldS) => {
searchBar.value?.focus()
})
watch(searchText, newTerm => {
debouncedSearch(newTerm)
})
// Watch for changes in champions data and update filtered champions
watch(
champions,
newChampions => {
filteredChampions.value = [...newChampions]
},
{ immediate: true }
)
// Navigation
async function navigateToChampion(championAlias: string): Promise<void> {
try {
await navigateTo(`/champion/${championAlias.toLowerCase()}`)
} catch (error) {
console.error('Navigation error:', error)
}
}
// Initialize filtered champions
onMounted(() => {
filteredChampions.value = [...champions.value]
})
// Error handling
const hasErrors = computed(() => championsError.value || lanesError.value)
const isLoading = computed(() => loadingChampions.value || loadingLanes.value)
</script>
<template>
<div>
<!-- Loading state -->
<div v-if="isLoading" class="loading-state">
<div class="loading-spinner" />
<p>Loading champions...</p>
</div>
<!-- Error state -->
<div v-else-if="hasErrors" class="error-state">
<p>Failed to load champion data. Please refresh the page.</p>
</div>
<!-- Main content -->
<div v-else>
<div class="search-lanefilter-container">
<LaneFilter id="cs-lanefilter" @filter-change="filterChampionsByLane" />
<input
ref="searchBar"
v-model="searchText"
class="search-bar"
type="text"
placeholder="Search a champion"
@keyup.enter="
() => filteredChampions.length > 0 && navigateToChampion(filteredChampions[0].alias)
"
/>
</div>
<!-- Empty state -->
<div v-if="filteredChampions.length === 0" class="empty-state">
<p>No champions found. Try a different search or filter.</p>
</div>
<div v-else class="champion-container">
<NuxtLink
v-for="champion in filteredChampions"
:key="champion.id"
:to="'/champion/' + champion.alias.toLowerCase()"
style="width: fit-content; height: fit-content"
>
<div class="cs-champion-img-container">
<NuxtImg
format="webp"
class="cs-champion-img"
:src="CDRAGON_BASE + mapPath(champion.squarePortraitPath)"
:alt="champion.name"
/>
</div>
</NuxtLink>
</div>
</div>
</div>
</template>
<style>
/* Loading and error states */
.loading-state,
.error-state,
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--color-on-surface);
font-size: 1.2rem;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid var(--color-surface);
border-top: 4px solid var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-state p {
margin: 0;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
.error-state {
color: var(--color-error);
}
.empty-state {
opacity: 0.7;
}
.search-bar {
width: 400px;
height: 60px;
background-color: var(--color-surface-darker);
font-size: 1.75rem;
border-radius: 12px;
border: none;
padding-left: 10px;
}
.search-bar:focus {
border: 2px solid var(--color-on-surface);
outline: none;
}
#cs-lanefilter {
margin: auto;
margin-right: 20px;
}
.search-lanefilter-container {
width: fit-content;
margin: auto;
display: flex;
align-items: center;
}
.champion-container {
width: 90%;
height: auto;
display: grid;
grid-template-columns: repeat(auto-fit, 128px);
grid-gap: 10px;
justify-content: center;
margin: auto;
margin-top: 20px;
margin-bottom: 20px;
}
.cs-champion-img-container {
overflow: hidden;
width: 120px;
height: 120px;
border: 1px solid var(--color-surface);
}
.cs-champion-img-container:hover {
border: 1px solid var(--color-on-surface);
}
.cs-champion-img {
width: 116px;
height: 116px;
transform: translate(4px, 4px) scale(1.2, 1.2);
user-select: none;
}
@media only screen and (max-width: 920px) {
.search-lanefilter-container {
flex-direction: column;
}
#cs-lanefilter {
margin-right: auto;
}
.champion-container {
width: 100%;
}
}
@media only screen and (max-width: 450px) {
.search-bar {
width: 92%;
height: 40px;
}
.cs-champion-img-container {
width: 80px;
height: 80px;
}
.cs-champion-img {
width: 76px;
height: 76px;
}
.champion-container {
grid-template-columns: repeat(auto-fit, 80px);
}
.search-lanefilter-container {
margin-top: 10px;
}
}
</style>