Better dev experience, better front page
All checks were successful
pipeline / build-and-push-images (push) Successful in 5m30s

This commit is contained in:
2026-01-20 21:20:13 +01:00
parent de9406a583
commit 4df99a4312
16 changed files with 1419 additions and 197 deletions

View File

@@ -1,82 +1,214 @@
<script setup lang="ts">
const {data: championsData} : ChampionsResponse = await useFetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json")
const champions = championsData.value.slice(1)
.filter((champion) =>
!champion.name.includes("Doom Bot"))
.sort((a, b) => {
if(a.name < b.name) return -1;
if(a.name > b.name) return 1;
return 0;
})
import { debounce, isEmpty } from '~/utils/helpers';
const {data: championsLanes} : {data: Ref<Array<ChampionData>>} = await useFetch("/api/champions")
const lanesMap : Map<string, Array<LaneData>> = new Map()
for(let champion of championsLanes.value) {
lanesMap.set(champion.alias, champion.lanes)
}
// 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";
const filteredChampions = ref(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
});
const searchBar = useTemplateRef("searchBar")
watch(searchBar, (newS, oldS) => {searchBar.value?.focus()})
const searchText = ref("")
watch(searchText, (newT, oldT) => {
filteredChampions.value = champions.filter((champion) => champion.name.toLowerCase().includes(searchText.value.toLowerCase()))
})
// Data processing
const champions = computed(() => {
if (!championsData.value || !Array.isArray(championsData.value)) return [];
function filterToLane(filter: number) {
switch(filter) {
case 0:
return "TOP";
case 1:
return "JUNGLE";
case 2:
return "MIDDLE";
case 3:
return "BOTTOM";
case 4:
return "UTILITY";
return championsData.value.slice(1)
.filter((champion: any) => !champion.name.includes("Doom Bot"))
.sort((a: any, b: any) => 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 onLaneFilterChange(newValue: number) {
if(newValue != -1) {
filteredChampions.value = champions.filter((champion) => {
const lanes : Array<LaneData> | undefined = lanesMap.get(champion.alias.toLowerCase())
if(lanes == undefined) return false;
function filterChampionsByLane(laneFilter: number): void {
if (laneFilter === -1) {
filteredChampions.value = [...champions.value];
return;
}
return lanes.reduce((acc : boolean, current : {data:string, count:number}) =>
acc || (current.data == filterToLane(newValue)), false)
})
}
else {
filteredChampions.value = champions
}
const laneName = filterToLane(laneFilter);
filteredChampions.value = champions.value.filter((champion: any) => {
const championLanes = lanesMap.value.get(champion.alias.toLowerCase());
if (!championLanes) return false;
return championLanes.some(lane => lane.data === laneName);
});
}
async function submit() {
await navigateTo("/champion/" + filteredChampions.value[0].alias.toLowerCase())
// Search functionality
const debouncedSearch = debounce((searchTerm: string) => {
if (isEmpty(searchTerm)) {
filteredChampions.value = [...champions.value];
} else {
filteredChampions.value = champions.value.filter((champion: any) =>
champion.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}
}, 300);
// Watchers
watch(searchBar, (newS, oldS) => {
searchBar.value?.focus();
});
watch(searchText, (newTerm) => {
debouncedSearch(newTerm);
});
// 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>
<div class="search-lanefilter-container">
<LaneFilter id="cs-lanefilter" @filter-change="(value : number) => onLaneFilterChange(value)"/>
<input @keyup.enter="submit" v-model="searchText" ref="searchBar" class="search-bar" type="text" placeholder="Search a champion"/>
</div>
<div class="champion-container">
<NuxtLink style="width: fit-content; height: fit-content;" v-for="champion in filteredChampions" :to="'/champion/' + champion.alias.toLowerCase()">
<div class="cs-champion-img-container">
<NuxtImg format="webp" class="cs-champion-img" :src="CDRAGON_BASE + mapPath(champion.squarePortraitPath)" :alt="champion.name"/>
<!-- Loading state -->
<div v-if="isLoading" class="loading-state">
<div class="loading-spinner"></div>
<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
@keyup.enter="() => filteredChampions.length > 0 && navigateToChampion(filteredChampions[0].alias)"
v-model="searchText"
ref="searchBar"
class="search-bar"
type="text"
placeholder="Search a champion"
/>
</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>
</NuxtLink>
</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;
@@ -115,11 +247,6 @@ async function submit() {
grid-gap: 10px;
justify-content: center;
/* overflow-x: hidden;
overflow-y: scroll;
scrollbar-color: var(--color-on-surface) var(--color-surface-darker);
scrollbar-width: thin; */
margin: auto;
margin-top: 20px;
margin-bottom: 20px;
@@ -170,4 +297,4 @@ async function submit() {
margin-top: 10px;
}
}
</style>
</style>

View File

@@ -1,39 +1,105 @@
<script setup lang="ts">
import { isEmpty, deepClone } from '~/utils/helpers';
const props = defineProps<{
builds: Builds
}>()
builds: Builds;
loading?: boolean;
error?: boolean;
}>();
const {data : items} : ItemResponse = await useFetch(CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/items.json")
const itemMap = reactive(new Map())
for(let item of items.value) {
itemMap.set(item.id, item)
}
// Constants
const ITEMS_API_URL = CDRAGON_BASE + "plugins/rcp-be-lol-game-data/global/default/v1/items.json";
const builds = ref(JSON.parse(JSON.stringify(props.builds)))
watch(() => props.builds, () => {
builds.value = JSON.parse(JSON.stringify(props.builds))
trimBuilds(builds.value)
trimLateGameItems(builds.value)
})
trimBuilds(builds.value)
trimLateGameItems(builds.value)
// State
const { data: items, pending: loadingItems, error: itemsError } = await useFetch(ITEMS_API_URL);
const itemMap = ref<Map<number, any>>(new Map());
function trimBuilds(builds : Builds) {
builds.tree.children.splice(1, builds.tree.children.length - 1)
if(builds.tree.children[0] != null && builds.tree.children[0] != undefined)
builds.tree.children[0].children.splice(1, builds.tree.children[0].children.length - 1)
}
function trimLateGameItems(builds: Builds) {
function trimLateGameItemsFromTree(tree: ItemTree) {
const foundIndex = builds.lateGame.findIndex((x) => x.data === tree.data)
if(foundIndex != -1) builds.lateGame.splice(foundIndex, 1)
for(let children of tree.children) {
trimLateGameItemsFromTree(children)
// Initialize item map
watch(items, (newItems) => {
try {
const itemsData = newItems || [];
if (Array.isArray(itemsData)) {
const map = new Map<number, any>();
for (const item of itemsData) {
if (item?.id) {
map.set(item.id, item);
}
}
itemMap.value = map;
}
trimLateGameItemsFromTree(builds.tree)
} catch (error) {
console.error('Error initializing item map:', error);
}
}, { immediate: true });
// Builds management
const builds = ref<Builds>(deepClone(props.builds));
watch(() => props.builds, (newBuilds) => {
builds.value = deepClone(newBuilds);
trimBuilds(builds.value);
trimLateGameItems(builds.value);
}, { deep: true });
// Initialize with trimmed builds
onMounted(() => {
trimBuilds(builds.value);
trimLateGameItems(builds.value);
});
/**
* Trim builds tree to show only primary build paths
*/
function trimBuilds(builds: Builds): void {
if (!builds?.tree?.children) return;
// Keep only the first child (primary build path)
builds.tree.children.splice(1, builds.tree.children.length - 1);
// For the primary path, keep only the first child of the first child
if (builds.tree.children[0]?.children) {
builds.tree.children[0].children.splice(1, builds.tree.children[0].children.length - 1);
}
}
/**
* Remove items from lateGame that are already in the build tree
*/
function trimLateGameItems(builds: Builds): void {
if (!builds?.tree || isEmpty(builds.lateGame)) return;
function trimLateGameItemsFromTree(tree: ItemTree): void {
const foundIndex = builds.lateGame.findIndex((x) => x.data === tree.data);
if (foundIndex !== -1) {
builds.lateGame.splice(foundIndex, 1);
}
for (const child of tree.children || []) {
trimLateGameItemsFromTree(child);
}
}
trimLateGameItemsFromTree(builds.tree);
}
/**
* Get item data safely
*/
function getItemData(itemId: number): any {
return itemMap.value.get(itemId) || { iconPath: '' };
}
/**
* Calculate percentage for item display
*/
function getItemPercentage(item: { count: number }, total: number): string {
if (total <= 0) return '0%';
return ((item.count / total) * 100).toFixed(0) + '%';
}
// Error and loading states
const hasError = computed(() => itemsError.value || props.error);
const isLoading = computed(() => loadingItems.value || props.loading);
</script>
<template>