Better dev experience, better front page
All checks were successful
pipeline / build-and-push-images (push) Successful in 5m30s
All checks were successful
pipeline / build-and-push-images (push) Successful in 5m30s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
1
frontend/package-lock.json
generated
1
frontend/package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@nuxt/image": "^1.10.0",
|
||||
"@nuxtjs/seo": "^3.0.3",
|
||||
"chart.js": "^4.5.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"mongodb": "^6.10.0",
|
||||
"nuxt": "^3.17.5",
|
||||
"nuxt-umami": "^3.2.1",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@nuxt/image": "^1.10.0",
|
||||
"@nuxtjs/seo": "^3.0.3",
|
||||
"chart.js": "^4.5.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"mongodb": "^6.10.0",
|
||||
"nuxt": "^3.17.5",
|
||||
"nuxt-umami": "^3.2.1",
|
||||
|
||||
@@ -1,44 +1,74 @@
|
||||
declare global {
|
||||
type ItemTree = {
|
||||
count: number
|
||||
data: number
|
||||
children: Array<ItemTree>
|
||||
}
|
||||
type Builds = {
|
||||
start: Array<{count: number, data: number}>
|
||||
tree: ItemTree
|
||||
bootsFirst: number
|
||||
boots: Array<{count: number, data: number}>
|
||||
lateGame: Array<{count: number, data: number}>
|
||||
suppItems?: Array<{count: number, data: number}>
|
||||
}
|
||||
type Rune = {
|
||||
count: number
|
||||
primaryStyle: number
|
||||
secondaryStyle: number
|
||||
selections: Array<number>
|
||||
pickrate: number
|
||||
}
|
||||
type LaneData = {
|
||||
data: string
|
||||
count: number
|
||||
winningMatches: number
|
||||
losingMatches: number
|
||||
winrate: number
|
||||
pickrate: number
|
||||
runes?: Array<Rune>
|
||||
builds?: Builds
|
||||
/**
|
||||
* Represents an item in the build tree
|
||||
*/
|
||||
interface ItemTree {
|
||||
count: number;
|
||||
data: number;
|
||||
children: ItemTree[];
|
||||
}
|
||||
|
||||
type ChampionData = {
|
||||
id: number
|
||||
name: string
|
||||
alias: string
|
||||
gameCount: number
|
||||
winrate: number
|
||||
pickrate: number
|
||||
lanes: Array<LaneData>
|
||||
/**
|
||||
* Represents champion build information
|
||||
*/
|
||||
interface Builds {
|
||||
start: Array<{count: number, data: number}>;
|
||||
tree: ItemTree;
|
||||
bootsFirst: number;
|
||||
boots: Array<{count: number, data: number}>;
|
||||
lateGame: Array<{count: number, data: number}>;
|
||||
suppItems?: Array<{count: number, data: number}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a rune configuration
|
||||
*/
|
||||
interface Rune {
|
||||
count: number;
|
||||
primaryStyle: number;
|
||||
secondaryStyle: number;
|
||||
selections: number[];
|
||||
pickrate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents lane-specific champion data
|
||||
*/
|
||||
interface LaneData {
|
||||
data: string;
|
||||
count: number;
|
||||
winningMatches: number;
|
||||
losingMatches: number;
|
||||
winrate: number;
|
||||
pickrate: number;
|
||||
runes?: Rune[];
|
||||
builds?: Builds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents complete champion data
|
||||
*/
|
||||
interface ChampionData {
|
||||
id: number;
|
||||
name: string;
|
||||
alias: string;
|
||||
gameCount: number;
|
||||
winrate: number;
|
||||
pickrate: number;
|
||||
lanes: LaneData[];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Champion summary from CDragon
|
||||
*/
|
||||
interface ChampionSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
alias: string;
|
||||
squarePortraitPath: string;
|
||||
// Add other relevant fields as needed
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
export {};
|
||||
|
||||
164
frontend/utils/helpers.ts
Normal file
164
frontend/utils/helpers.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Frontend utility functions for BuildPath application
|
||||
*/
|
||||
|
||||
/**
|
||||
* Debounce function to limit how often a function can be called
|
||||
* @param func Function to debounce
|
||||
* @param wait Time in milliseconds to wait before calling the function
|
||||
* @returns Debounced function
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return function(...args: Parameters<T>): void {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe JSON parsing with error handling
|
||||
* @param data Data to parse
|
||||
* @param defaultValue Default value to return if parsing fails
|
||||
* @returns Parsed JSON or default value
|
||||
*/
|
||||
export function safeJsonParse<T>(data: string, defaultValue: T): T {
|
||||
try {
|
||||
return JSON.parse(data) as T;
|
||||
} catch (error) {
|
||||
console.error('JSON parse error:', error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number as percentage with specified decimal places
|
||||
* @param value Number to format
|
||||
* @param decimals Number of decimal places
|
||||
* @returns Formatted percentage string
|
||||
*/
|
||||
export function formatPercentage(value: number, decimals: number = 0): string {
|
||||
return (value * 100).toFixed(decimals) + '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize first letter of string
|
||||
* @param str String to capitalize
|
||||
* @returns Capitalized string
|
||||
*/
|
||||
export function capitalize(str: string): string {
|
||||
if (!str) return '';
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert lane position to readable name
|
||||
* @param position Lane position string
|
||||
* @returns Readable lane name
|
||||
*/
|
||||
export function getLaneName(position: string): string {
|
||||
const laneMap: Record<string, string> = {
|
||||
'top': 'Top',
|
||||
'jungle': 'Jungle',
|
||||
'middle': 'Middle',
|
||||
'bottom': 'Bottom',
|
||||
'utility': 'Support'
|
||||
};
|
||||
return laneMap[position.toLowerCase()] || position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate champion image URL
|
||||
* @param championAlias Champion alias
|
||||
* @returns Full image URL
|
||||
*/
|
||||
export function getChampionImageUrl(championAlias: string): string {
|
||||
return `/img/champions/${championAlias.toLowerCase()}.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate item image URL
|
||||
* @param itemId Item ID
|
||||
* @returns Full item image URL
|
||||
*/
|
||||
export function getItemImageUrl(itemId: number): string {
|
||||
return `${CDRAGON_BASE}plugins/rcp-be-lol-game-data/global/default/v1/items/${itemId}.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate rune image URL
|
||||
* @param runeId Rune ID
|
||||
* @returns Full rune image URL
|
||||
*/
|
||||
export function getRuneImageUrl(runeId: number): string {
|
||||
return `${CDRAGON_BASE}plugins/rcp-be-lol-game-data/global/default/v1/perks/${runeId}.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format large numbers with abbreviations (K, M)
|
||||
* @param num Number to format
|
||||
* @returns Formatted string
|
||||
*/
|
||||
export function formatLargeNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone an object
|
||||
* @param obj Object to clone
|
||||
* @returns Cloned object
|
||||
*/
|
||||
export function deepClone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value is empty (null, undefined, empty string, empty array, empty object)
|
||||
* @param value Value to check
|
||||
* @returns True if value is empty
|
||||
*/
|
||||
export function isEmpty(value: any): boolean {
|
||||
if (value === null || value === undefined) return true;
|
||||
if (typeof value === 'string' && value.trim() === '') return true;
|
||||
if (Array.isArray(value) && value.length === 0) return true;
|
||||
if (typeof value === 'object' && Object.keys(value).length === 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get winrate color based on value
|
||||
* @param winrate Winrate value (0-1)
|
||||
* @returns CSS color class
|
||||
*/
|
||||
export function getWinrateColor(winrate: number): string {
|
||||
if (winrate > 0.55) return 'text-green-500';
|
||||
if (winrate < 0.45) return 'text-red-500';
|
||||
return 'text-yellow-500';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to readable string
|
||||
* @param ms Duration in milliseconds
|
||||
* @returns Formatted duration string
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user