Compare commits
11 Commits
main
...
frontend-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
930cbf5a18
|
|||
|
f6cf2c8a8c
|
|||
|
8c9da868f4
|
|||
|
45fa841f80
|
|||
|
c362d6b12a
|
|||
|
7833780bcb
|
|||
|
20ccb20738
|
|||
|
3e9a8295b2
|
|||
|
ea27a0d6f8
|
|||
|
fe128c0848
|
|||
| dc09d10f07 |
@@ -9,7 +9,8 @@
|
|||||||
"import-matches": "node scripts/setup-db.js import-matches",
|
"import-matches": "node scripts/setup-db.js import-matches",
|
||||||
"import-patches": "node scripts/setup-db.js import-patches",
|
"import-patches": "node scripts/setup-db.js import-patches",
|
||||||
"generate-stats": "node scripts/setup-db.js generate-stats",
|
"generate-stats": "node scripts/setup-db.js generate-stats",
|
||||||
"status": "node scripts/setup-db.js status"
|
"status": "node scripts/setup-db.js status",
|
||||||
|
"fetch-cdragon": "node scripts/fetch-cdragon.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mongodb": "^6.10.0"
|
"mongodb": "^6.10.0"
|
||||||
|
|||||||
34
dev/scripts/fetch-cdragon.js
Normal file
34
dev/scripts/fetch-cdragon.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Cache directory - use dev/cdragon by default
|
||||||
|
const cacheDir = process.env.CDRAGON_CACHE_DIR || path.join(__dirname, '..', 'data', 'cdragon');
|
||||||
|
|
||||||
|
// Dev MongoDB credentials (matching docker-compose.yml defaults)
|
||||||
|
const mongoUser = process.env.MONGO_USER || 'root';
|
||||||
|
const mongoPass = process.env.MONGO_PASS || 'password';
|
||||||
|
const mongoHost = process.env.MONGO_HOST || 'localhost:27017';
|
||||||
|
|
||||||
|
// Run patch_detector with the cache directory and dev MongoDB credentials
|
||||||
|
const patchDetector = spawn('npx', ['tsx', '../patch_detector/index.ts'], {
|
||||||
|
cwd: path.join(__dirname, '..'),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
CDRAGON_CACHE_DIR: cacheDir,
|
||||||
|
MONGO_USER: mongoUser,
|
||||||
|
MONGO_PASS: mongoPass,
|
||||||
|
MONGO_HOST: mongoHost
|
||||||
|
},
|
||||||
|
stdio: 'inherit'
|
||||||
|
});
|
||||||
|
|
||||||
|
patchDetector.on('close', (code) => {
|
||||||
|
process.exit(code || 0);
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@ async function setupDatabase() {
|
|||||||
const patchFile = path.join(dataDir, "patches.json");
|
const patchFile = path.join(dataDir, "patches.json");
|
||||||
if(!fs.existsSync(dataDir) || !fs.existsSync(patchFile)) {
|
if(!fs.existsSync(dataDir) || !fs.existsSync(patchFile)) {
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
console.log('📥 No data files found. Downloading latest snapshot...');
|
console.log('🚫 No data files found. Downloading latest snapshot...');
|
||||||
await downloadAndExtractSnapshot();
|
await downloadAndExtractSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +90,11 @@ async function setupDatabase() {
|
|||||||
console.log('✅ Skipping matches import - sufficient data already present');
|
console.log('✅ Skipping matches import - sufficient data already present');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Run match collector to generate stats
|
// 7. Fetch CDragon data for the current patch
|
||||||
|
console.log('🎮 Fetching CDragon data...');
|
||||||
|
await fetchCDragonData();
|
||||||
|
|
||||||
|
// 8. Run match collector to generate stats
|
||||||
console.log('📊 Generating champion stats...');
|
console.log('📊 Generating champion stats...');
|
||||||
await generateChampionStats();
|
await generateChampionStats();
|
||||||
|
|
||||||
@@ -322,6 +326,24 @@ async function generateChampionStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchCDragonData() {
|
||||||
|
try {
|
||||||
|
console.log('🔄 Running CDragon fetcher...');
|
||||||
|
|
||||||
|
// Run the fetch-cdragon script
|
||||||
|
const fetchCDragonPath = path.join(__dirname, 'fetch-cdragon.js');
|
||||||
|
execSync(`node ${fetchCDragonPath}`, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: path.join(__dirname, '..')
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ CDragon data fetched');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to fetch CDragon data:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getMatchCount(patchVersion) {
|
async function getMatchCount(patchVersion) {
|
||||||
const client = new MongoClient(getMongoUri());
|
const client = new MongoClient(getMongoUri());
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
--color-surface: #312e2c;
|
--color-surface: #312e2c;
|
||||||
--color-on-surface: #b7b8e1;
|
--color-on-surface: #b7b8e1;
|
||||||
--color-surface-darker: #1f1d1c;
|
--color-surface-darker: #1f1d1c;
|
||||||
|
--color-gold: #ffd700;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Font setting */
|
/* Font setting */
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
import { debounce, isEmpty } from '~/utils/helpers'
|
import { debounce, isEmpty } from '~/utils/helpers'
|
||||||
|
|
||||||
// Constants
|
// 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 CHAMPIONS_API_URL = '/api/champions'
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@@ -11,7 +9,7 @@ const {
|
|||||||
data: championsData,
|
data: championsData,
|
||||||
pending: loadingChampions,
|
pending: loadingChampions,
|
||||||
error: championsError
|
error: championsError
|
||||||
} = useFetch(CDRAGON_CHAMPIONS_URL, {
|
} = useFetch('/api/cdragon/champion-summary', {
|
||||||
key: 'champions-data',
|
key: 'champions-data',
|
||||||
lazy: false,
|
lazy: false,
|
||||||
server: false // Disable server-side fetching to avoid hydration issues
|
server: false // Disable server-side fetching to avoid hydration issues
|
||||||
|
|||||||
151
frontend/components/build/BuildVariantSelector.vue
Normal file
151
frontend/components/build/BuildVariantSelector.vue
Normal 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>
|
||||||
128
frontend/components/build/CompactRuneSelector.vue
Normal file
128
frontend/components/build/CompactRuneSelector.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile fix: Remove negative margin to prevent overlap with runes */
|
||||||
|
@media only screen and (max-width: 900px) {
|
||||||
|
.compact-rune-selector {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
66
frontend/components/build/ItemRow.vue
Normal file
66
frontend/components/build/ItemRow.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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">
|
||||||
|
<template v-for="item in items.slice(0, maxItems)" :key="item.data">
|
||||||
|
<ItemIcon
|
||||||
|
v-if="itemMap.get(item.data)"
|
||||||
|
:item="itemMap.get(item.data)!"
|
||||||
|
:show-pickrate="true"
|
||||||
|
:pickrate="item.count / totalCount"
|
||||||
|
:size="48"
|
||||||
|
class="item-cell"
|
||||||
|
/>
|
||||||
|
<div v-else class="item-placeholder" />
|
||||||
|
</template>
|
||||||
|
</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-placeholder {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--color-on-surface);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
92
frontend/components/build/SummonerSpells.vue
Normal file
92
frontend/components/build/SummonerSpells.vue
Normal 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>
|
||||||
269
frontend/components/build/Viewer.vue
Normal file
269
frontend/components/build/Viewer.vue
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { getHighestPickrateBuildIndex, getFirstCoreItems } from '~/utils/buildHelpers'
|
||||||
|
import { MOCK_SUMMONER_SPELLS } from '~/utils/mockData'
|
||||||
|
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<{
|
||||||
|
count: number
|
||||||
|
primaryStyle: number
|
||||||
|
secondaryStyle: number
|
||||||
|
selections: Array<number>
|
||||||
|
pickrate: number
|
||||||
|
}>
|
||||||
|
builds: Builds
|
||||||
|
summonerSpells?: Array<{ id: number; count: number; pickrate: number }>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const currentlySelectedBuild = ref(0)
|
||||||
|
|
||||||
|
// Use composables for data fetching
|
||||||
|
const { itemMap } = useItemMap()
|
||||||
|
const { summonerSpellMap } = useSummonerSpellMap()
|
||||||
|
|
||||||
|
// Use composable for rune styles
|
||||||
|
const { perks, primaryStyles, secondaryStyles, keystoneIds } = useRuneStyles(toRef(props, 'runes'))
|
||||||
|
|
||||||
|
// Use composable for builds management
|
||||||
|
const { builds } = useBuilds(toRef(props, 'builds'))
|
||||||
|
|
||||||
|
// Summoner spells data - use提供的 or fall back to mock
|
||||||
|
const displaySummonerSpells = computed(() =>
|
||||||
|
props.summonerSpells && props.summonerSpells.length > 0
|
||||||
|
? props.summonerSpells
|
||||||
|
: MOCK_SUMMONER_SPELLS
|
||||||
|
)
|
||||||
|
|
||||||
|
// Computed properties using utility functions
|
||||||
|
const firstCoreItems = computed(() => getFirstCoreItems(props.runes, builds.value))
|
||||||
|
|
||||||
|
const highestPickrateBuildIndex = computed(() => getHighestPickrateBuildIndex(props.runes))
|
||||||
|
|
||||||
|
// Reset selected build when runes change
|
||||||
|
watch(
|
||||||
|
() => props.runes,
|
||||||
|
() => {
|
||||||
|
currentlySelectedBuild.value = 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function selectRune(index: number): void {
|
||||||
|
currentlySelectedBuild.value = index
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="build-viewer">
|
||||||
|
<!-- 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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Main Build Content -->
|
||||||
|
<div class="build-content">
|
||||||
|
<!-- Left Column: Summoner Spells + Runes -->
|
||||||
|
<div class="build-left-column">
|
||||||
|
<!-- Summoner Spells -->
|
||||||
|
<SummonerSpells :spells="displaySummonerSpells" :summoner-spell-map="summonerSpellMap" />
|
||||||
|
|
||||||
|
<!-- Rune Page -->
|
||||||
|
<div class="rune-section">
|
||||||
|
<h3 class="section-title">Runes</h3>
|
||||||
|
<div class="rune-page-wrapper">
|
||||||
|
<RunePage
|
||||||
|
v-if="runes[currentlySelectedBuild]"
|
||||||
|
:primary-style-id="runes[currentlySelectedBuild].primaryStyle"
|
||||||
|
:secondary-style-id="runes[currentlySelectedBuild].secondaryStyle"
|
||||||
|
: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>
|
||||||
|
|
||||||
|
<!-- Right Column: Items -->
|
||||||
|
<div class="build-right-column">
|
||||||
|
<h3 class="section-title">Items</h3>
|
||||||
|
|
||||||
|
<!-- Start/Support + Boots Container -->
|
||||||
|
<div class="item-row-group">
|
||||||
|
<!-- Start Items -->
|
||||||
|
<ItemRow
|
||||||
|
v-if="!builds.suppItems"
|
||||||
|
label="Start"
|
||||||
|
:items="builds.start"
|
||||||
|
:item-map="itemMap"
|
||||||
|
:total-count="builds.tree.count"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Support Items -->
|
||||||
|
<ItemRow
|
||||||
|
v-if="builds.suppItems"
|
||||||
|
label="Support"
|
||||||
|
:items="builds.suppItems"
|
||||||
|
:item-map="itemMap"
|
||||||
|
:total-count="builds.tree.count"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Boots (regular or rush) -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Core Items Tree -->
|
||||||
|
<div class="item-row">
|
||||||
|
<span class="item-row-label">Core</span>
|
||||||
|
<ItemTree :tree="builds.tree" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Late Game -->
|
||||||
|
<ItemRow
|
||||||
|
label="Late Game"
|
||||||
|
:items="builds.lateGame.slice(0, 6)"
|
||||||
|
:item-map="itemMap"
|
||||||
|
:total-count="builds.tree.count"
|
||||||
|
:max-items="6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.build-viewer {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Build Content */
|
||||||
|
.build-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 80px;
|
||||||
|
padding: 0 24px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #4a9eff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left Column: Runes + Summoner Spells */
|
||||||
|
.build-left-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rune-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rune-page-wrapper {
|
||||||
|
margin-top: -45px;
|
||||||
|
transform: scale(0.7);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right Column: Items */
|
||||||
|
.build-right-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: Mobile */
|
||||||
|
@media only screen and (max-width: 900px) {
|
||||||
|
.build-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 40px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-left-column {
|
||||||
|
order: 1;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-right-column {
|
||||||
|
order: 2;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rune-section,
|
||||||
|
.summoner-spells-section {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rune-page-wrapper {
|
||||||
|
transform: scale(1);
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
defineProps<{
|
|
||||||
title: string
|
|
||||||
bootsFirst?: number
|
|
||||||
sizePerc?: number
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
:style="
|
|
||||||
sizePerc != undefined && sizePerc != null ? 'max-height: ' + sizePerc * 600 + 'px;' : ''
|
|
||||||
"
|
|
||||||
class="item-box"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style="display: flex; flex-direction: column; justify-content: center; align-items: center"
|
|
||||||
>
|
|
||||||
<h2 class="item-box-title">{{ title }}</h2>
|
|
||||||
<h5 v-if="bootsFirst != undefined && bootsFirst != null" style="margin: auto">
|
|
||||||
({{ (bootsFirst * 100).toFixed(2) }}%)
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.item-box {
|
|
||||||
border: 1px solid var(--color-on-surface);
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
margin: 10px;
|
|
||||||
|
|
||||||
width: fit-content;
|
|
||||||
height: 600px;
|
|
||||||
}
|
|
||||||
.item-box-title {
|
|
||||||
font-variant: small-caps;
|
|
||||||
text-align: center;
|
|
||||||
margin: 10px;
|
|
||||||
}
|
|
||||||
@media only screen and (max-width: 1000px) {
|
|
||||||
.item-box {
|
|
||||||
width: 95%;
|
|
||||||
height: fit-content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
137
frontend/components/item/ItemIcon.vue
Normal file
137
frontend/components/item/ItemIcon.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: Item
|
||||||
|
size?: number
|
||||||
|
showPickrate?: boolean
|
||||||
|
pickrate?: number
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose the icon element for external use (e.g., arrow drawing)
|
||||||
|
const iconElement = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
iconElement
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
size: 48,
|
||||||
|
showPickrate: false,
|
||||||
|
pickrate: 0,
|
||||||
|
class: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tooltip state - encapsulated in this component
|
||||||
|
const tooltipState = reactive({
|
||||||
|
show: false,
|
||||||
|
item: null as Item | null,
|
||||||
|
x: 0,
|
||||||
|
y: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleMouseEnter = (event: MouseEvent) => {
|
||||||
|
tooltipState.item = props.item
|
||||||
|
|
||||||
|
// Calculate optimal position to keep tooltip within viewport
|
||||||
|
// Don't estimate height - position based on cursor location
|
||||||
|
const tooltipWidth = 300 // Maximum width from CSS
|
||||||
|
const padding = 10 // Minimum padding from edges
|
||||||
|
const offset = 15 // Distance from cursor
|
||||||
|
|
||||||
|
// Get viewport dimensions
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
|
||||||
|
// Right edge detection: if we're in the right half, position to the left
|
||||||
|
let x = event.clientX + offset
|
||||||
|
if (event.clientX + tooltipWidth + offset > viewportWidth - padding) {
|
||||||
|
x = event.clientX - tooltipWidth - offset
|
||||||
|
// Clamp if still off-screen
|
||||||
|
if (x < padding) {
|
||||||
|
x = padding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom edge detection: if we're in the bottom half, position above
|
||||||
|
// Use a smaller offset for vertical to keep it close
|
||||||
|
let y = event.clientY + offset
|
||||||
|
if (event.clientY > viewportHeight * 0.7) {
|
||||||
|
y = event.clientY - offset - 200 // Position ~200px above
|
||||||
|
// Clamp if too high
|
||||||
|
if (y < padding) {
|
||||||
|
y = padding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Y is within reasonable bounds
|
||||||
|
y = Math.min(y, viewportHeight - padding)
|
||||||
|
|
||||||
|
tooltipState.x = x
|
||||||
|
tooltipState.y = y
|
||||||
|
tooltipState.show = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
tooltipState.show = false
|
||||||
|
tooltipState.item = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemIconPath = computed(() => CDRAGON_BASE + mapPath(props.item.iconPath))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="item-icon-wrapper" @mouseleave="handleMouseLeave">
|
||||||
|
<div
|
||||||
|
ref="iconElement"
|
||||||
|
class="item-icon"
|
||||||
|
:class="props.class"
|
||||||
|
:style="{ width: size + 'px', height: size + 'px' }"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
|
>
|
||||||
|
<NuxtImg :src="itemIconPath" :alt="item.name || 'Item'" class="item-img" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span v-if="showPickrate" class="item-pickrate"> {{ (pickrate * 100).toFixed(0) }}% </span>
|
||||||
|
|
||||||
|
<ItemTooltip
|
||||||
|
:show="tooltipState.show"
|
||||||
|
:item="tooltipState.item"
|
||||||
|
:x="tooltipState.x"
|
||||||
|
:y="tooltipState.y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.item-icon-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--color-on-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: help;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-pickrate {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
349
frontend/components/item/ItemTooltip.vue
Normal file
349
frontend/components/item/ItemTooltip.vue
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: Item | null
|
||||||
|
show: boolean
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// Parse description and convert to styled HTML
|
||||||
|
const formatDescription = (description?: string) => {
|
||||||
|
if (!description) return ''
|
||||||
|
|
||||||
|
// Replace <br> and other structural tags
|
||||||
|
const html = description
|
||||||
|
.replace(/<br\s*\/?>/gi, '<br>')
|
||||||
|
.replace(/<br><br><br>/gi, '') // Remove triple breaks
|
||||||
|
.replace(/<br><br>/gi, '') // Remove double breaks
|
||||||
|
.replace(/<mainText>/gi, '')
|
||||||
|
.replace(/<\/mainText>/gi, '')
|
||||||
|
.replace(/<stats>/gi, '<div class="tooltip-stats">')
|
||||||
|
.replace(/<\/stats>/gi, '</div>')
|
||||||
|
.replace(/<passive>/gi, '<span class="tag-passive">')
|
||||||
|
.replace(/<\/passive>/gi, '</span>:')
|
||||||
|
.replace(/<active>/gi, '<span class="tag-active">')
|
||||||
|
.replace(/<\/active>/gi, '</span>')
|
||||||
|
.replace(/<keyword>/gi, '<span class="tag-keyword">')
|
||||||
|
.replace(/<\/keyword>/gi, '</span>')
|
||||||
|
.replace(/<attention>/gi, '<span class="stat-highlight">')
|
||||||
|
.replace(/<\/attention>/gi, '</span>')
|
||||||
|
.replace(/<keywordMajor>/gi, '<span class="tag-keyword-major">')
|
||||||
|
.replace(/<\/keywordMajor>/gi, '</span>')
|
||||||
|
.replace(/<keywordStealth>/gi, '<span class="tag-keyword-stealth">')
|
||||||
|
.replace(/<\/keywordStealth>/gi, '</span>')
|
||||||
|
.replace(/<status>/gi, '<span class="tag-status">')
|
||||||
|
.replace(/<\/status>/gi, '</span>')
|
||||||
|
.replace(/<speed>/gi, '<span class="tag-speed">')
|
||||||
|
.replace(/<\/speed>/gi, '</span>')
|
||||||
|
.replace(/<scaleMana>/gi, '<span class="tag-scale-mana">')
|
||||||
|
.replace(/<\/scaleMana>/gi, '</span>')
|
||||||
|
.replace(/<scaleHealth>/gi, '<span class="tag-scale-health">')
|
||||||
|
.replace(/<\/scaleHealth>/gi, '</span>')
|
||||||
|
.replace(/<scaleAP>/gi, '<span class="tag-scale-ap">')
|
||||||
|
.replace(/<\/scaleAP>/gi, '</span>')
|
||||||
|
.replace(/<scaleAD>/gi, '<span class="tag-scale-ad">')
|
||||||
|
.replace(/<\/scaleAD>/gi, '</span>')
|
||||||
|
.replace(/<scaleArmor>/gi, '<span class="tag-scale-armor">')
|
||||||
|
.replace(/<\/scaleArmor>/gi, '</span>')
|
||||||
|
.replace(/<scaleMR>/gi, '<span class="tag-scale-mr">')
|
||||||
|
.replace(/<\/scaleMR>/gi, '</span>')
|
||||||
|
.replace(/<scaleLevel>/gi, '<span class="tag-scale-level">')
|
||||||
|
.replace(/<\/scaleLevel>/gi, '</span>')
|
||||||
|
.replace(/<spellName>/gi, '<span class="tag-spellname">')
|
||||||
|
.replace(/<\/spellName>/gi, '</span>')
|
||||||
|
.replace(/<unique>/gi, '<span class="tag-unique">UNIQUE</span>')
|
||||||
|
.replace(/<\/unique>/gi, '')
|
||||||
|
.replace(/<rarityMythic>/gi, '<span class="tag-rarity-mythic">Mythic</span>')
|
||||||
|
.replace(/<\/rarityMythic>/gi, '')
|
||||||
|
.replace(/<rarityLegendary>/gi, '<span class="tag-rarity-legendary">Legendary</span>')
|
||||||
|
.replace(/<\/rarityLegendary>/gi, '')
|
||||||
|
.replace(/<rarityGeneric>/gi, '<span class="tag-rarity-generic">Epic</span>')
|
||||||
|
.replace(/<\/rarityGeneric>/gi, '')
|
||||||
|
.replace(/<rules>/gi, '<div class="tag-rules">')
|
||||||
|
.replace(/<\/rules>/gi, '</div>')
|
||||||
|
.replace(/<flavorText>/gi, '<div class="tag-flavor">')
|
||||||
|
.replace(/<\/flavorText>/gi, '</div>')
|
||||||
|
.replace(/<li>/gi, '<div class="tag-list">')
|
||||||
|
.replace(/<\/li>/gi, '</div>')
|
||||||
|
.replace(/<font color='([^']+)'>/gi, '<span style="color: $1">')
|
||||||
|
.replace(/<\/font>/gi, '</span>')
|
||||||
|
.replace(/<b>/gi, '<strong>')
|
||||||
|
.replace(/<\/b>/gi, '</strong>')
|
||||||
|
.replace(/\[@@[^@]*@@\]/g, ' ') // Remove stat placeholders
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedDescription = computed(() =>
|
||||||
|
props.item ? formatDescription(props.item.description) : ''
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="show && item"
|
||||||
|
class="item-tooltip"
|
||||||
|
:style="{
|
||||||
|
left: x + 'px',
|
||||||
|
top: y + 'px'
|
||||||
|
}"
|
||||||
|
@mouseenter.stop
|
||||||
|
>
|
||||||
|
<div class="tooltip-header">
|
||||||
|
<NuxtImg class="tooltip-icon" :src="CDRAGON_BASE + mapPath(item.iconPath)" />
|
||||||
|
<div class="tooltip-title">
|
||||||
|
<h3>{{ item.name || 'Unknown Item' }}</h3>
|
||||||
|
<span v-if="item.priceTotal" class="tooltip-gold"> {{ item.priceTotal }} Gold </span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="item.plaintext" class="tooltip-plaintext">
|
||||||
|
{{ item.plaintext }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
v-if="formattedDescription"
|
||||||
|
class="tooltip-description"
|
||||||
|
v-html="formattedDescription"
|
||||||
|
></div>
|
||||||
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.item-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-on-surface);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
max-width: 300px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--color-on-surface);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-title h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-gold {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-gold);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-plaintext {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats section */
|
||||||
|
.tooltip-stats {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--color-on-surface-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-stats :deep(.stat-highlight) {
|
||||||
|
color: #ffcc00;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag styles */
|
||||||
|
.tooltip-description :deep(.tag-passive) {
|
||||||
|
color: #4a9eff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-active) {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-keyword) {
|
||||||
|
color: #ffd700;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-keyword-major) {
|
||||||
|
color: #ff8c00;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-keyword-stealth) {
|
||||||
|
color: #9b59b6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-status) {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-speed),
|
||||||
|
.tooltip-description :deep(.tag-scale-mana),
|
||||||
|
.tooltip-description :deep(.tag-scale-health),
|
||||||
|
.tooltip-description :deep(.tag-scale-ap),
|
||||||
|
.tooltip-description :deep(.tag-scale-ad),
|
||||||
|
.tooltip-description :deep(.tag-scale-armor),
|
||||||
|
.tooltip-description :deep(.tag-scale-mr),
|
||||||
|
.tooltip-description :deep(.tag-scale-level) {
|
||||||
|
color: #3498db;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-healing),
|
||||||
|
.tooltip-description :deep(.tag-health) {
|
||||||
|
color: #2ecc71;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-shield) {
|
||||||
|
color: #3498db;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-magic-damage) {
|
||||||
|
color: #9b59b6;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-physical-damage) {
|
||||||
|
color: #e67e22;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-true-damage) {
|
||||||
|
color: #c0392b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-onhit) {
|
||||||
|
background: rgba(52, 152, 219, 0.1);
|
||||||
|
color: #3498db;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-spellname) {
|
||||||
|
color: #1abc9c;
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-unique) {
|
||||||
|
color: #f39c12;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-rarity-mythic) {
|
||||||
|
color: #ff5252;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-rarity-legendary) {
|
||||||
|
color: #ff9800;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-rarity-generic) {
|
||||||
|
color: #ffd54f;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-rules) {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-left: 2px solid var(--color-on-surface-dim);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-flavor) {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(74, 222, 255, 0.1);
|
||||||
|
border-left: 2px solid #4a9eff;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description :deep(.tag-list) {
|
||||||
|
margin: 4px 0;
|
||||||
|
padding-left: 12px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-description strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,18 +9,19 @@ defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
mount: [end: Element]
|
mount: [end: Element]
|
||||||
refresh: []
|
refresh: []
|
||||||
|
parentReady: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { data: items } = useFetch<Array<{ id: number; iconPath: string }>>(
|
const { data: items, pending: itemsLoading } = useFetch<Array<Item>>('/api/cdragon/items', {
|
||||||
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/items.json',
|
|
||||||
{
|
|
||||||
lazy: true, // Don't block rendering
|
lazy: true, // Don't block rendering
|
||||||
server: false // Client-side only
|
server: false // Client-side only
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
// Track image loading state
|
||||||
|
const imagesLoaded = ref(false)
|
||||||
|
|
||||||
// Create item map reactively
|
// Create item map reactively
|
||||||
const itemMap = reactive(new Map<number, { id: number; iconPath: string }>())
|
const itemMap = reactive(new Map<number, Item>())
|
||||||
watch(
|
watch(
|
||||||
items,
|
items,
|
||||||
newItems => {
|
newItems => {
|
||||||
@@ -34,19 +35,85 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
function getItemIconPath(itemId: number): string {
|
const startTreeItem = useTemplateRef('start')
|
||||||
const item = itemMap.get(itemId)
|
const arrows: Array<svgdomarrowsLinePath> = []
|
||||||
return item ? CDRAGON_BASE + mapPath(item.iconPath) : ''
|
const pendingChildMounts: Array<Element> = []
|
||||||
|
|
||||||
|
// Get the actual icon element for arrow drawing
|
||||||
|
const startElement = computed(() => startTreeItem.value?.iconElement ?? null)
|
||||||
|
|
||||||
|
// Function to wait for an image to load
|
||||||
|
function waitForImageLoad(imgElement: HTMLImageElement): Promise<void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (imgElement.complete) {
|
||||||
|
requestAnimationFrame(() => resolve())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const start: Ref<Element | null> = useTemplateRef('start')
|
imgElement.addEventListener(
|
||||||
const arrows: Array<svgdomarrowsLinePath> = []
|
'load',
|
||||||
|
() => {
|
||||||
|
requestAnimationFrame(() => resolve())
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
|
imgElement.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => {
|
||||||
|
requestAnimationFrame(() => resolve())
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// Only refresh arrows and emit if start element is available
|
// Wait for next tick to ensure DOM is ready
|
||||||
if (start.value) {
|
await nextTick()
|
||||||
|
|
||||||
|
// Wait for items to be loaded
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
if (!itemsLoading.value) {
|
||||||
|
resolve()
|
||||||
|
} else {
|
||||||
|
const unwatch = watch(itemsLoading, loading => {
|
||||||
|
if (!loading) {
|
||||||
|
unwatch()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (startElement.value) {
|
||||||
|
// Wait for the ItemIcon to load its image
|
||||||
|
const imgElement = startElement.value.querySelector('img')
|
||||||
|
if (imgElement) {
|
||||||
|
await waitForImageLoad(imgElement as HTMLImageElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now that image is loaded and DOM is ready, draw arrows
|
||||||
|
imagesLoaded.value = true
|
||||||
|
|
||||||
|
// Notify children that parent is ready
|
||||||
|
emit('parentReady')
|
||||||
|
|
||||||
|
// Draw any pending arrows from children that mounted before we were ready
|
||||||
|
if (pendingChildMounts.length > 0) {
|
||||||
|
await nextTick()
|
||||||
|
for (const childEnd of pendingChildMounts) {
|
||||||
|
drawArrow(startElement.value!, childEnd)
|
||||||
|
}
|
||||||
|
pendingChildMounts.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use multiple requestAnimationFrame to ensure rendering is complete
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
refreshArrows()
|
refreshArrows()
|
||||||
emit('mount', start.value)
|
emit('mount', startElement.value!)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -57,10 +124,17 @@ onBeforeUpdate(() => {
|
|||||||
arrows.splice(0, arrows.length)
|
arrows.splice(0, arrows.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUpdated(() => {
|
onUpdated(async () => {
|
||||||
if (start.value) {
|
await nextTick()
|
||||||
|
|
||||||
|
if (startElement.value && imagesLoaded.value) {
|
||||||
|
// Redraw arrows after DOM update
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
refreshArrows()
|
refreshArrows()
|
||||||
emit('mount', start.value)
|
emit('mount', startElement.value!)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -71,7 +145,6 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function drawArrow(start: Element, end: Element) {
|
function drawArrow(start: Element, end: Element) {
|
||||||
// console.log("drawArrow(", start, ", ", end, ")")
|
|
||||||
if (start == null || end == null) return
|
if (start == null || end == null) return
|
||||||
|
|
||||||
const arrow = new svgdomarrowsLinePath({
|
const arrow = new svgdomarrowsLinePath({
|
||||||
@@ -89,16 +162,21 @@ function drawArrow(start: Element, end: Element) {
|
|||||||
left: 0
|
left: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: 'stroke:var(--color-on-surface);stroke-width:3;fill:transparent;',
|
style: 'stroke:var(--color-on-surface);stroke-width:2;fill:transparent;pointer-events:none;',
|
||||||
appendTo: document.body
|
appendTo: document.body
|
||||||
})
|
})
|
||||||
arrows.push(arrow)
|
arrows.push(arrow)
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshArrows() {
|
function refreshArrows() {
|
||||||
|
// Double requestAnimationFrame to ensure layout is complete
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
for (const arrow of arrows) {
|
for (const arrow of arrows) {
|
||||||
arrow.redraw()
|
arrow.redraw()
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redraw arrows on window resize
|
// Redraw arrows on window resize
|
||||||
@@ -110,12 +188,36 @@ addEventListener('scroll', _ => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleSubtreeMount(end: Element) {
|
function handleSubtreeMount(end: Element) {
|
||||||
if (start.value) {
|
if (startElement.value) {
|
||||||
drawArrow(start.value, end)
|
if (imagesLoaded.value) {
|
||||||
|
// Parent is ready, draw arrow immediately
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
drawArrow(startElement.value!, end)
|
||||||
refreshArrows()
|
refreshArrows()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Parent not ready yet, store for later
|
||||||
|
pendingChildMounts.push(end)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleParentReady() {
|
||||||
|
// Parent became ready, redraw all our arrows
|
||||||
|
if (startElement.value && imagesLoaded.value) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
refreshArrows()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Propagate to children
|
||||||
|
emit('parentReady')
|
||||||
|
}
|
||||||
|
|
||||||
function handleRefresh() {
|
function handleRefresh() {
|
||||||
refreshArrows()
|
refreshArrows()
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
@@ -123,37 +225,64 @@ function handleRefresh() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div style="display: flex; align-items: center">
|
<div class="item-tree-container">
|
||||||
<div
|
<div v-if="tree.data != undefined && tree.data != null" class="item-tree-node">
|
||||||
v-if="tree.data != undefined && tree.data != null"
|
<ItemIcon
|
||||||
style="width: fit-content; height: fit-content"
|
v-if="itemMap.get(tree.data)"
|
||||||
>
|
|
||||||
<img
|
|
||||||
ref="start"
|
ref="start"
|
||||||
class="item-img"
|
:item="itemMap.get(tree.data)!"
|
||||||
width="64"
|
:show-pickrate="true"
|
||||||
height="64"
|
:pickrate="parentCount ? tree.count / parentCount : 0"
|
||||||
:alt="tree.data.toString()"
|
:size="48"
|
||||||
:src="getItemIconPath(tree.data)"
|
class="item-tree-img"
|
||||||
/>
|
/>
|
||||||
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
|
|
||||||
{{ ((tree.count / parentCount!!) * 100).toFixed(0) }}%
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-left: 30px">
|
<div class="item-tree-children">
|
||||||
<div
|
<div v-for="child in tree.children" :key="child.data" class="item-tree-child">
|
||||||
v-for="child in tree.children"
|
|
||||||
:key="child.data"
|
|
||||||
style="width: fit-content; height: fit-content"
|
|
||||||
>
|
|
||||||
<ItemTree
|
<ItemTree
|
||||||
:tree="child"
|
:tree="child"
|
||||||
:parent-count="tree.count"
|
:parent-count="tree.count"
|
||||||
@refresh="handleRefresh"
|
@refresh="handleRefresh"
|
||||||
@mount="end => handleSubtreeMount(end)"
|
@mount="end => handleSubtreeMount(end)"
|
||||||
|
@parent-ready="handleParentReady"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.item-tree-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-tree-node {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: fit-content;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-tree-children {
|
||||||
|
margin-left: 32px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-tree-child {
|
||||||
|
width: fit-content;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive */
|
||||||
|
@media only screen and (max-width: 900px) {
|
||||||
|
.item-tree-children {
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,349 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { isEmpty, deepClone } from '~/utils/helpers'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
builds: Builds
|
|
||||||
loading?: boolean
|
|
||||||
error?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Constants
|
|
||||||
const ITEMS_API_URL = CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/items.json'
|
|
||||||
|
|
||||||
// State
|
|
||||||
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())
|
|
||||||
|
|
||||||
// Initialize item map
|
|
||||||
watch(
|
|
||||||
items,
|
|
||||||
newItems => {
|
|
||||||
try {
|
|
||||||
const itemsData = newItems || []
|
|
||||||
if (Array.isArray(itemsData)) {
|
|
||||||
const map = new Map<number, unknown>()
|
|
||||||
for (const item of itemsData) {
|
|
||||||
if (item?.id) {
|
|
||||||
map.set(item.id, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
itemMap.value = map
|
|
||||||
}
|
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error and loading states
|
|
||||||
const _hasError = computed(() => itemsError.value || props.error)
|
|
||||||
const _isLoading = computed(() => loadingItems.value || props.loading)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div id="iv-container">
|
|
||||||
<div>
|
|
||||||
<!-- Start items -->
|
|
||||||
<ItemBox v-if="builds.suppItems == undefined || builds.suppItems == null" title="start">
|
|
||||||
<div class="iv-items-container">
|
|
||||||
<div
|
|
||||||
v-for="item in builds.start"
|
|
||||||
:key="item.data"
|
|
||||||
style="margin-left: 5px; margin-right: 5px"
|
|
||||||
>
|
|
||||||
<NuxtImg
|
|
||||||
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
|
|
||||||
class="item-img"
|
|
||||||
width="64"
|
|
||||||
height="64"
|
|
||||||
:alt="item.data.toString()"
|
|
||||||
: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">
|
|
||||||
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ItemBox>
|
|
||||||
<!-- Supp items -->
|
|
||||||
<ItemBox v-if="builds.suppItems != undefined && builds.suppItems != null" title="supp">
|
|
||||||
<div class="iv-items-container">
|
|
||||||
<div
|
|
||||||
v-for="item in builds.suppItems"
|
|
||||||
:key="item.data"
|
|
||||||
style="margin-left: 5px; margin-right: 5px"
|
|
||||||
>
|
|
||||||
<NuxtImg
|
|
||||||
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
|
|
||||||
class="item-img"
|
|
||||||
width="64"
|
|
||||||
height="64"
|
|
||||||
:alt="item.data.toString()"
|
|
||||||
: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">
|
|
||||||
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ItemBox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Boots first : when champion rush boots -->
|
|
||||||
<ItemBox v-if="builds.bootsFirst > 0.5" title="boots rush" :boots-first="builds.bootsFirst">
|
|
||||||
<div class="iv-items-container">
|
|
||||||
<div
|
|
||||||
v-for="item in builds.boots"
|
|
||||||
:key="item.data"
|
|
||||||
style="margin-left: 5px; margin-right: 5px"
|
|
||||||
>
|
|
||||||
<NuxtImg
|
|
||||||
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
|
|
||||||
class="item-img"
|
|
||||||
width="64"
|
|
||||||
height="64"
|
|
||||||
:alt="item.data.toString()"
|
|
||||||
: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">
|
|
||||||
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ItemBox>
|
|
||||||
|
|
||||||
<!-- Core items -->
|
|
||||||
<ItemBox title="core">
|
|
||||||
<ItemTree style="margin: auto; width: fit-content" :tree="builds.tree" />
|
|
||||||
</ItemBox>
|
|
||||||
|
|
||||||
<!-- Boots -->
|
|
||||||
<ItemBox v-if="builds.bootsFirst <= 0.5" title="boots">
|
|
||||||
<div class="iv-items-container">
|
|
||||||
<div
|
|
||||||
v-for="item in builds.boots.slice(0, 4)"
|
|
||||||
:key="item.data"
|
|
||||||
style="margin-left: 5px; margin-right: 5px"
|
|
||||||
>
|
|
||||||
<NuxtImg
|
|
||||||
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
|
|
||||||
class="item-img"
|
|
||||||
width="64"
|
|
||||||
height="64"
|
|
||||||
:alt="item.data.toString()"
|
|
||||||
: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">
|
|
||||||
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ItemBox>
|
|
||||||
|
|
||||||
<!-- Late game items -->
|
|
||||||
<ItemBox title="late game">
|
|
||||||
<div id="iv-late-game-container">
|
|
||||||
<div class="iv-items-container">
|
|
||||||
<div
|
|
||||||
v-for="item in builds.lateGame.slice(0, 4)"
|
|
||||||
:key="item.data"
|
|
||||||
style="margin-left: 5px; margin-right: 5px"
|
|
||||||
>
|
|
||||||
<NuxtImg
|
|
||||||
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
|
|
||||||
class="item-img"
|
|
||||||
width="64"
|
|
||||||
height="64"
|
|
||||||
:alt="item.data.toString()"
|
|
||||||
: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">
|
|
||||||
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="builds.lateGame.length > 4" class="iv-items-container">
|
|
||||||
<div
|
|
||||||
v-for="item in builds.lateGame.slice(4, 8)"
|
|
||||||
:key="item.data"
|
|
||||||
style="margin-left: 5px; margin-right: 5px"
|
|
||||||
>
|
|
||||||
<NuxtImg
|
|
||||||
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
|
|
||||||
class="item-img"
|
|
||||||
width="64"
|
|
||||||
height="64"
|
|
||||||
:alt="item.data.toString()"
|
|
||||||
: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">
|
|
||||||
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ItemBox>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
#iv-container {
|
|
||||||
display: flex;
|
|
||||||
width: fit-content;
|
|
||||||
height: fit-content;
|
|
||||||
}
|
|
||||||
.iv-items-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: fit-content;
|
|
||||||
height: fit-content;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
.item-img {
|
|
||||||
border: 1px solid var(--color-on-surface);
|
|
||||||
margin: 10px;
|
|
||||||
}
|
|
||||||
#iv-late-game-container {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 1000px) {
|
|
||||||
#iv-container {
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.iv-items-container {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
.item-img {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
#iv-late-game-container {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -10,7 +10,7 @@ const emit = defineEmits<{
|
|||||||
stateChange: [state: string, lane: number]
|
stateChange: [state: string, lane: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const state = ref('runes')
|
const state = ref('build')
|
||||||
const laneState = ref(0)
|
const laneState = ref(0)
|
||||||
|
|
||||||
function handleStateChange(newState: string, newLane: number) {
|
function handleStateChange(newState: string, newLane: number) {
|
||||||
@@ -49,16 +49,10 @@ if (route.path.startsWith('/tierlist/')) {
|
|||||||
/>
|
/>
|
||||||
<div class="nav-buttons">
|
<div class="nav-buttons">
|
||||||
<button
|
<button
|
||||||
:class="['nav-button', { active: state == 'runes' && laneState == i }]"
|
:class="['nav-button', { active: state == 'build' && laneState == i }]"
|
||||||
@click="handleStateChange('runes', i)"
|
@click="handleStateChange('build', i)"
|
||||||
>
|
>
|
||||||
Runes
|
Build
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['nav-button', { active: state == 'items' && laneState == i }]"
|
|
||||||
@click="handleStateChange('items', i)"
|
|
||||||
>
|
|
||||||
Items
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:class="['nav-button', { active: state == 'alternatives' && laneState == i }]"
|
:class="['nav-button', { active: state == 'alternatives' && laneState == i }]"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const emit = defineEmits<{
|
|||||||
stateChange: [state: string, lane: number]
|
stateChange: [state: string, lane: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const state = ref('runes')
|
const state = ref('build')
|
||||||
const laneState = ref(0)
|
const laneState = ref(0)
|
||||||
|
|
||||||
function handleStateChange(newState: string, newLane: number) {
|
function handleStateChange(newState: string, newLane: number) {
|
||||||
@@ -39,9 +39,9 @@ if (route.path.startsWith('/tierlist/')) {
|
|||||||
|
|
||||||
<div class="sidebar-container">
|
<div class="sidebar-container">
|
||||||
<Logo
|
<Logo
|
||||||
font-size="2.6rem"
|
font-size="2rem"
|
||||||
img-width="60"
|
img-width="45"
|
||||||
style="padding-left: 15px; padding-right: 15px; margin-top: 30px"
|
style="padding-left: 10px; padding-right: 10px; margin-top: 20px"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-for="(lane, i) in championLanes" :key="i">
|
<div v-for="(lane, i) in championLanes" :key="i">
|
||||||
@@ -49,22 +49,22 @@ if (route.path.startsWith('/tierlist/')) {
|
|||||||
style="
|
style="
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 30px;
|
margin-top: 20px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<h1 style="font-size: 2.4rem; padding-left: 20px">{{ championName }}</h1>
|
<h1 style="font-size: 1.8rem; padding-left: 15px">{{ championName }}</h1>
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
format="webp"
|
format="webp"
|
||||||
style="margin-left: 10px"
|
style="margin-left: 8px"
|
||||||
width="40"
|
width="30"
|
||||||
height="40"
|
height="30"
|
||||||
:src="LANE_IMAGES[lanePositionToIndex(lane.data)]"
|
:src="LANE_IMAGES[lanePositionToIndex(lane.data)]"
|
||||||
/>
|
/>
|
||||||
<h2
|
<h2
|
||||||
v-if="championName != null && championName != undefined && championName.length < 8"
|
v-if="championName != null && championName != undefined && championName.length < 8"
|
||||||
style="margin-left: 5px; font-size: 1.8rem; font-weight: 200"
|
style="margin-left: 5px; font-size: 1.4rem; font-weight: 200"
|
||||||
>
|
>
|
||||||
{{ POSITIONS_STR[lanePositionToIndex(lane.data.toLowerCase())] }}
|
{{ POSITIONS_STR[lanePositionToIndex(lane.data.toLowerCase())] }}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -72,21 +72,12 @@ if (route.path.startsWith('/tierlist/')) {
|
|||||||
|
|
||||||
<h2
|
<h2
|
||||||
:class="
|
:class="
|
||||||
'sidebar-link ' + (state == 'runes' && laneState == i ? 'sidebar-link-selected' : '')
|
'sidebar-link ' + (state == 'build' && laneState == i ? 'sidebar-link-selected' : '')
|
||||||
"
|
"
|
||||||
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
|
style="margin-top: 8px; font-size: 1.5rem; padding-left: 25px"
|
||||||
@click="handleStateChange('runes', i)"
|
@click="handleStateChange('build', i)"
|
||||||
>
|
>
|
||||||
Runes
|
Build
|
||||||
</h2>
|
|
||||||
<h2
|
|
||||||
:class="
|
|
||||||
'sidebar-link ' + (state == 'items' && laneState == i ? 'sidebar-link-selected' : '')
|
|
||||||
"
|
|
||||||
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
|
|
||||||
@click="handleStateChange('items', i)"
|
|
||||||
>
|
|
||||||
Items
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<h2
|
<h2
|
||||||
@@ -94,7 +85,7 @@ if (route.path.startsWith('/tierlist/')) {
|
|||||||
'sidebar-link ' +
|
'sidebar-link ' +
|
||||||
(state == 'alternatives' && laneState == i ? 'sidebar-link-selected' : '')
|
(state == 'alternatives' && laneState == i ? 'sidebar-link-selected' : '')
|
||||||
"
|
"
|
||||||
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
|
style="margin-top: 8px; font-size: 1.5rem; padding-left: 25px"
|
||||||
@click="handleStateChange('alternatives', i)"
|
@click="handleStateChange('alternatives', i)"
|
||||||
>
|
>
|
||||||
Alternatives
|
Alternatives
|
||||||
@@ -103,15 +94,15 @@ if (route.path.startsWith('/tierlist/')) {
|
|||||||
:class="
|
:class="
|
||||||
'sidebar-link ' + (state == 'matchups' && laneState == i ? 'sidebar-link-selected' : '')
|
'sidebar-link ' + (state == 'matchups' && laneState == i ? 'sidebar-link-selected' : '')
|
||||||
"
|
"
|
||||||
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
|
style="margin-top: 8px; font-size: 1.5rem; padding-left: 25px"
|
||||||
@click="handleStateChange('matchups', i)"
|
@click="handleStateChange('matchups', i)"
|
||||||
>
|
>
|
||||||
Matchups
|
Matchups
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="tierlistList == true" style="margin-top: 30px">
|
<div v-if="tierlistList == true" style="margin-top: 20px">
|
||||||
<h2 style="padding-left: 20px; font-size: 2.4rem; margin-bottom: 10px">Tierlist</h2>
|
<h2 style="padding-left: 15px; font-size: 1.8rem; margin-bottom: 8px">Tierlist</h2>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="(pos, i) in POSITIONS"
|
v-for="(pos, i) in POSITIONS"
|
||||||
:key="i"
|
:key="i"
|
||||||
@@ -121,16 +112,16 @@ if (route.path.startsWith('/tierlist/')) {
|
|||||||
<div
|
<div
|
||||||
:class="selected == pos ? 'sidebar-link-selected' : ''"
|
:class="selected == pos ? 'sidebar-link-selected' : ''"
|
||||||
class="sidebar-link"
|
class="sidebar-link"
|
||||||
style="padding-left: 35px; display: flex; align-items: center"
|
style="padding-left: 25px; display: flex; align-items: center"
|
||||||
>
|
>
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
format="webp"
|
format="webp"
|
||||||
width="40"
|
width="30"
|
||||||
height="40"
|
height="30"
|
||||||
:src="LANE_IMAGES[i]"
|
:src="LANE_IMAGES[i]"
|
||||||
:alt="POSITIONS_STR[i]"
|
:alt="POSITIONS_STR[i]"
|
||||||
/>
|
/>
|
||||||
<h3 style="font-size: 2.1rem; font-weight: 200; margin-left: 10px">
|
<h3 style="font-size: 1.6rem; font-weight: 200; margin-left: 8px">
|
||||||
{{ POSITIONS_STR[i] }}
|
{{ POSITIONS_STR[i] }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,14 +130,14 @@ if (route.path.startsWith('/tierlist/')) {
|
|||||||
|
|
||||||
<div style="position: absolute; bottom: 0; margin-bottom: 10px; padding-left: 10px">
|
<div style="position: absolute; bottom: 0; margin-bottom: 10px; padding-left: 10px">
|
||||||
<template v-if="stats">
|
<template v-if="stats">
|
||||||
<h3 style="font-size: 23px; font-weight: 200">Patch {{ stats.patch }}</h3>
|
<h3 style="font-size: 18px; font-weight: 200">Patch {{ stats.patch }}</h3>
|
||||||
<h3 style="font-size: 23px; font-weight: 200">{{ stats.count }} games</h3>
|
<h3 style="font-size: 18px; font-weight: 200">{{ stats.count }} games</h3>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<h3 style="font-size: 23px; font-weight: 200; opacity: 0.5">Loading stats...</h3>
|
<h3 style="font-size: 18px; font-weight: 200; opacity: 0.5">Loading stats...</h3>
|
||||||
</template>
|
</template>
|
||||||
<NuxtLink to="/about"><h3>About</h3></NuxtLink>
|
<NuxtLink to="/about"><h3>About</h3></NuxtLink>
|
||||||
<h2 style="font-size: 12px; font-weight: 200; margin-top: 5px">
|
<h2 style="font-size: 10px; font-weight: 200; margin-top: 3px">
|
||||||
BuildPath isn't endorsed by Riot Games and doesn't reflect the views or opinions of Riot
|
BuildPath isn't endorsed by Riot Games and doesn't reflect the views or opinions of Riot
|
||||||
Games or anyone officially involved in producing or managing Riot Games properties. Riot
|
Games or anyone officially involved in producing or managing Riot Games properties. Riot
|
||||||
Games, and all associated properties are trademarks or registered trademarks of Riot Games,
|
Games, and all associated properties are trademarks or registered trademarks of Riot Games,
|
||||||
|
|||||||
@@ -8,17 +8,13 @@ const props = defineProps<{
|
|||||||
const primaryStyle: Ref<PerkStyle> = ref({ id: 0, name: '', iconPath: '', slots: [] })
|
const primaryStyle: Ref<PerkStyle> = ref({ id: 0, name: '', iconPath: '', slots: [] })
|
||||||
const secondaryStyle: Ref<PerkStyle> = ref({ id: 0, name: '', iconPath: '', slots: [] })
|
const secondaryStyle: Ref<PerkStyle> = ref({ id: 0, name: '', iconPath: '', slots: [] })
|
||||||
|
|
||||||
const { data: perks_data }: PerksResponse = await useFetch(
|
const { data: perks_data }: PerksResponse = await useFetch('/api/cdragon/perks')
|
||||||
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/perks.json'
|
|
||||||
)
|
|
||||||
const perks = reactive(new Map())
|
const perks = reactive(new Map())
|
||||||
for (const perk of perks_data.value) {
|
for (const perk of perks_data.value) {
|
||||||
perks.set(perk.id, perk)
|
perks.set(perk.id, perk)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: stylesData }: PerkStylesResponse = await useFetch(
|
const { data: stylesData }: PerkStylesResponse = await useFetch('/api/cdragon/perkstyles')
|
||||||
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json'
|
|
||||||
)
|
|
||||||
watch(
|
watch(
|
||||||
() => props.primaryStyleId,
|
() => props.primaryStyleId,
|
||||||
async (_newP, _oldP) => {
|
async (_newP, _oldP) => {
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
runes: Array<{
|
|
||||||
count: number
|
|
||||||
primaryStyle: number
|
|
||||||
secondaryStyle: number
|
|
||||||
selections: Array<number>
|
|
||||||
pickrate: number
|
|
||||||
}>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const currentlySelectedPage = ref(0)
|
|
||||||
const primaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
|
|
||||||
const secondaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
|
|
||||||
const keystoneIds: Ref<Array<number>> = ref(Array(props.runes.length))
|
|
||||||
|
|
||||||
const { data: perks_data }: PerksResponse = await useFetch(
|
|
||||||
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/perks.json'
|
|
||||||
)
|
|
||||||
const perks = reactive(new Map())
|
|
||||||
for (const perk of perks_data.value) {
|
|
||||||
perks.set(perk.id, perk)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: stylesData }: PerkStylesResponse = await useFetch(
|
|
||||||
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json'
|
|
||||||
)
|
|
||||||
watch(
|
|
||||||
() => props.runes,
|
|
||||||
(_newRunes, _oldRunes) => {
|
|
||||||
currentlySelectedPage.value = 0
|
|
||||||
primaryStyles.value = Array(props.runes.length)
|
|
||||||
secondaryStyles.value = Array(props.runes.length)
|
|
||||||
keystoneIds.value = Array(props.runes.length)
|
|
||||||
|
|
||||||
refreshStylesKeystones()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function refreshStylesKeystones() {
|
|
||||||
for (const style of stylesData.value.styles) {
|
|
||||||
for (const rune of props.runes) {
|
|
||||||
if (style.id == rune.primaryStyle) {
|
|
||||||
primaryStyles.value[props.runes.indexOf(rune)] = style
|
|
||||||
for (const perk of style.slots[0].perks) {
|
|
||||||
if (rune.selections.includes(perk)) {
|
|
||||||
keystoneIds.value[props.runes.indexOf(rune)] = perk
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (style.id == rune.secondaryStyle) {
|
|
||||||
secondaryStyles.value[props.runes.indexOf(rune)] = style
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshStylesKeystones()
|
|
||||||
|
|
||||||
function runeSelect(index: number) {
|
|
||||||
currentlySelectedPage.value = index
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div style="width: fit-content">
|
|
||||||
<RunePage
|
|
||||||
v-if="runes[currentlySelectedPage] != undefined && runes[currentlySelectedPage] != null"
|
|
||||||
style="margin: auto; width: fit-content"
|
|
||||||
:primary-style-id="runes[currentlySelectedPage].primaryStyle"
|
|
||||||
:secondary-style-id="runes[currentlySelectedPage].secondaryStyle"
|
|
||||||
:selection-ids="runes[currentlySelectedPage].selections"
|
|
||||||
/>
|
|
||||||
<div style="display: flex; margin-top: 20px; justify-content: center">
|
|
||||||
<div v-for="(_, i) in runes" :key="i" @click="runeSelect(i)">
|
|
||||||
<div
|
|
||||||
:class="
|
|
||||||
'rune-selector-entry ' +
|
|
||||||
(i == currentlySelectedPage ? 'rune-selector-entry-selected' : '')
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div class="rs-styles-container">
|
|
||||||
<NuxtImg
|
|
||||||
v-if="primaryStyles[i] != null && primaryStyles[i] != undefined"
|
|
||||||
class="rs-style-img"
|
|
||||||
style="margin: auto"
|
|
||||||
:src="CDRAGON_BASE + mapPath(primaryStyles[i].iconPath)"
|
|
||||||
/>
|
|
||||||
<NuxtImg
|
|
||||||
v-if="keystoneIds[i] != null && keystoneIds[i] != undefined"
|
|
||||||
class="rs-style-img"
|
|
||||||
width="34"
|
|
||||||
:src="CDRAGON_BASE + mapPath(perks.get(keystoneIds[i]).iconPath)"
|
|
||||||
/>
|
|
||||||
<NuxtImg
|
|
||||||
v-if="secondaryStyles[i] != null && secondaryStyles[i] != undefined"
|
|
||||||
class="rs-style-img"
|
|
||||||
style="margin: auto"
|
|
||||||
:src="CDRAGON_BASE + mapPath(secondaryStyles[i].iconPath)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 class="rs-pickrate">{{ (runes[i].pickrate * 100).toFixed(2) }}% pick.</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.rune-selector-entry {
|
|
||||||
width: 200px;
|
|
||||||
height: 120px;
|
|
||||||
|
|
||||||
margin-left: 10px;
|
|
||||||
margin-right: 10px;
|
|
||||||
|
|
||||||
border-radius: 8%;
|
|
||||||
border: 1px solid var(--color-on-surface);
|
|
||||||
}
|
|
||||||
.rune-selector-entry:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.rune-selector-entry-selected {
|
|
||||||
background-color: var(--color-surface-darker);
|
|
||||||
}
|
|
||||||
.rs-styles-container {
|
|
||||||
display: flex;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
.rs-pickrate {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: -40px;
|
|
||||||
padding-bottom: 40px;
|
|
||||||
}
|
|
||||||
@media only screen and (max-width: 650px) {
|
|
||||||
.rune-selector-entry {
|
|
||||||
width: 100px;
|
|
||||||
height: 60px;
|
|
||||||
|
|
||||||
margin-left: 5px;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
.rs-styles-container {
|
|
||||||
margin-top: 17px;
|
|
||||||
}
|
|
||||||
.rs-pickrate {
|
|
||||||
margin-top: 5px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
}
|
|
||||||
.rs-style-img {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
32
frontend/composables/useBuilds.ts
Normal file
32
frontend/composables/useBuilds.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Composable for managing build data with automatic trimming
|
||||||
|
* Handles deep cloning and tree manipulation
|
||||||
|
*/
|
||||||
|
import { deepClone } from '~/utils/helpers'
|
||||||
|
import { trimBuilds, trimLateGameItems } from '~/utils/buildHelpers'
|
||||||
|
|
||||||
|
export const useBuilds = (buildsProp: Ref<Builds>) => {
|
||||||
|
const builds = ref<Builds>(deepClone(buildsProp.value))
|
||||||
|
|
||||||
|
function trimBuildData(): void {
|
||||||
|
trimBuilds(builds.value)
|
||||||
|
trimLateGameItems(builds.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for changes and rebuild
|
||||||
|
watch(
|
||||||
|
() => buildsProp.value,
|
||||||
|
newBuilds => {
|
||||||
|
builds.value = deepClone(newBuilds)
|
||||||
|
trimBuildData()
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initial trim on mount
|
||||||
|
onMounted(() => {
|
||||||
|
trimBuildData()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { builds }
|
||||||
|
}
|
||||||
30
frontend/composables/useItemMap.ts
Normal file
30
frontend/composables/useItemMap.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Composable for fetching and managing item data from CDragon API
|
||||||
|
* Returns a reactive Map of item ID to item data
|
||||||
|
*/
|
||||||
|
export const useItemMap = () => {
|
||||||
|
const { data: items } = useFetch<Array<Item>>('/api/cdragon/items', {
|
||||||
|
lazy: true,
|
||||||
|
server: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const itemMap = ref<Map<number, Item>>(new Map())
|
||||||
|
|
||||||
|
watch(
|
||||||
|
items,
|
||||||
|
newItems => {
|
||||||
|
if (Array.isArray(newItems)) {
|
||||||
|
const map = new Map<number, Item>()
|
||||||
|
for (const item of newItems) {
|
||||||
|
if (item?.id) {
|
||||||
|
map.set(item.id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemMap.value = map
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
return { itemMap }
|
||||||
|
}
|
||||||
97
frontend/composables/useRuneStyles.ts
Normal file
97
frontend/composables/useRuneStyles.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Composable for fetching and managing rune styles and keystones
|
||||||
|
* Transforms rune data into format needed for display components
|
||||||
|
*/
|
||||||
|
export const useRuneStyles = (
|
||||||
|
runes: Ref<
|
||||||
|
Array<{
|
||||||
|
count: number
|
||||||
|
primaryStyle: number
|
||||||
|
secondaryStyle: number
|
||||||
|
selections: Array<number>
|
||||||
|
pickrate: number
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const primaryStyles = ref<Array<PerkStyle>>(Array(runes.value.length))
|
||||||
|
const secondaryStyles = ref<Array<PerkStyle>>(Array(runes.value.length))
|
||||||
|
const keystoneIds = ref<Array<number>>(Array(runes.value.length))
|
||||||
|
|
||||||
|
const { data: perksData } = useFetch('/api/cdragon/perks')
|
||||||
|
const { data: stylesData } = useFetch('/api/cdragon/perkstyles')
|
||||||
|
|
||||||
|
const perks = reactive(new Map<number, Perk>())
|
||||||
|
watch(
|
||||||
|
perksData,
|
||||||
|
newPerks => {
|
||||||
|
if (Array.isArray(newPerks)) {
|
||||||
|
perks.clear()
|
||||||
|
for (const perk of newPerks) {
|
||||||
|
if (perk?.id) {
|
||||||
|
perks.set(perk.id, perk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function refreshStylesKeystones(): void {
|
||||||
|
if (!stylesData.value?.styles) return
|
||||||
|
|
||||||
|
primaryStyles.value = Array(runes.value.length)
|
||||||
|
secondaryStyles.value = Array(runes.value.length)
|
||||||
|
keystoneIds.value = Array(runes.value.length)
|
||||||
|
|
||||||
|
for (const style of stylesData.value.styles) {
|
||||||
|
for (const rune of runes.value) {
|
||||||
|
const runeIndex = runes.value.indexOf(rune)
|
||||||
|
|
||||||
|
if (style.id === rune.primaryStyle) {
|
||||||
|
primaryStyles.value[runeIndex] = style
|
||||||
|
|
||||||
|
// Find keystone from first slot
|
||||||
|
if (style.slots?.[0]?.perks) {
|
||||||
|
for (const perk of style.slots[0].perks) {
|
||||||
|
if (rune.selections.includes(perk)) {
|
||||||
|
keystoneIds.value[runeIndex] = perk
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.id === rune.secondaryStyle) {
|
||||||
|
secondaryStyles.value[runeIndex] = style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh when styles data loads or runes change
|
||||||
|
watch(
|
||||||
|
[stylesData, runes],
|
||||||
|
() => {
|
||||||
|
refreshStylesKeystones()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset when runes array changes
|
||||||
|
watch(
|
||||||
|
() => runes.value.length,
|
||||||
|
() => {
|
||||||
|
primaryStyles.value = Array(runes.value.length)
|
||||||
|
secondaryStyles.value = Array(runes.value.length)
|
||||||
|
keystoneIds.value = Array(runes.value.length)
|
||||||
|
refreshStylesKeystones()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
perks,
|
||||||
|
primaryStyles,
|
||||||
|
secondaryStyles,
|
||||||
|
keystoneIds
|
||||||
|
}
|
||||||
|
}
|
||||||
33
frontend/composables/useSummonerSpellMap.ts
Normal file
33
frontend/composables/useSummonerSpellMap.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Composable for fetching and managing summoner spell data from CDragon API
|
||||||
|
* Returns a reactive Map of spell ID to spell data
|
||||||
|
*/
|
||||||
|
export const useSummonerSpellMap = () => {
|
||||||
|
const { data: summonerSpellsData } = useFetch<Array<SummonerSpell>>(
|
||||||
|
'/api/cdragon/summoner-spells',
|
||||||
|
{
|
||||||
|
lazy: true,
|
||||||
|
server: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const summonerSpellMap = ref<Map<number, SummonerSpell>>(new Map())
|
||||||
|
|
||||||
|
watch(
|
||||||
|
summonerSpellsData,
|
||||||
|
newData => {
|
||||||
|
if (Array.isArray(newData)) {
|
||||||
|
const map = new Map<number, SummonerSpell>()
|
||||||
|
for (const spell of newData) {
|
||||||
|
if (spell?.id) {
|
||||||
|
map.set(spell.id, spell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summonerSpellMap.value = map
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
return { summonerSpellMap }
|
||||||
|
}
|
||||||
@@ -30,7 +30,10 @@ export default withNuxt([
|
|||||||
ChampionsResponse: 'readonly',
|
ChampionsResponse: 'readonly',
|
||||||
ChampionResponse: 'readonly',
|
ChampionResponse: 'readonly',
|
||||||
ItemResponse: 'readonly',
|
ItemResponse: 'readonly',
|
||||||
MatchupData: 'readonly'
|
MatchupData: 'readonly',
|
||||||
|
Item: 'readonly',
|
||||||
|
SummonerSpell: 'readonly',
|
||||||
|
Perk: 'readonly'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const championAlias = route.params.alias as string
|
|||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const laneState = ref(0)
|
const laneState = ref(0)
|
||||||
const state = ref('runes')
|
const state = ref('build')
|
||||||
|
|
||||||
// Use useAsyncData with client-side fetching for faster initial page load
|
// Use useAsyncData with client-side fetching for faster initial page load
|
||||||
const {
|
const {
|
||||||
@@ -165,16 +165,10 @@ function fetchChampionData() {
|
|||||||
:game-count="lane.count || 0"
|
:game-count="lane.count || 0"
|
||||||
/>
|
/>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<LazyRuneSelector
|
<LazyBuildViewer
|
||||||
v-if="state == 'runes' && championData.gameCount > 0 && lane?.runes"
|
v-if="state == 'build' && championData.gameCount > 0 && lane?.runes && lane?.builds"
|
||||||
style="margin: auto; margin-top: 40px"
|
style="margin: auto; margin-top: 40px"
|
||||||
:runes="lane.runes"
|
:runes="lane.runes"
|
||||||
/>
|
|
||||||
</ClientOnly>
|
|
||||||
<ClientOnly>
|
|
||||||
<LazyItemViewer
|
|
||||||
v-if="state == 'items' && championData.gameCount > 0 && lane?.builds"
|
|
||||||
style="margin: auto; margin-top: 40px"
|
|
||||||
:builds="lane.builds"
|
:builds="lane.builds"
|
||||||
/>
|
/>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
@@ -216,8 +210,10 @@ function fetchChampionData() {
|
|||||||
}
|
}
|
||||||
#champion-content {
|
#champion-content {
|
||||||
margin-top: 64px;
|
margin-top: 64px;
|
||||||
margin-left: 39px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading state styles */
|
/* Loading state styles */
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const lane = route.params.lane as string
|
const lane = route.params.lane as string
|
||||||
|
|
||||||
const { data: championsData }: ChampionsResponse = await useFetch(
|
const { data: championsData }: ChampionsResponse = await useFetch('/api/cdragon/champion-summary')
|
||||||
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
|
|
||||||
)
|
|
||||||
|
|
||||||
const { data: championsLanes }: { data: Ref<Array<ChampionData>> } =
|
const { data: championsLanes }: { data: Ref<Array<ChampionData>> } =
|
||||||
await useFetch('/api/champions')
|
await useFetch('/api/champions')
|
||||||
|
|||||||
14
frontend/server/api/cdragon/champion-summary.ts
Normal file
14
frontend/server/api/cdragon/champion-summary.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { getChampionSummary } from '~/server/utils/cdragon-cache'
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
try {
|
||||||
|
const championSummary = await getChampionSummary()
|
||||||
|
return championSummary
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching champion summary:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Failed to fetch champion summary'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
14
frontend/server/api/cdragon/items.ts
Normal file
14
frontend/server/api/cdragon/items.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { getItems } from '~/server/utils/cdragon-cache'
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
try {
|
||||||
|
const items = await getItems()
|
||||||
|
return items
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching items:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Failed to fetch items'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
14
frontend/server/api/cdragon/perks.ts
Normal file
14
frontend/server/api/cdragon/perks.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { getPerks } from '~/server/utils/cdragon-cache'
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
try {
|
||||||
|
const perks = await getPerks()
|
||||||
|
return perks
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching perks:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Failed to fetch perks'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
14
frontend/server/api/cdragon/perkstyles.ts
Normal file
14
frontend/server/api/cdragon/perkstyles.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { getPerkStyles } from '~/server/utils/cdragon-cache'
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
try {
|
||||||
|
const perkStyles = await getPerkStyles()
|
||||||
|
return perkStyles
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching perk styles:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Failed to fetch perk styles'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
14
frontend/server/api/cdragon/summoner-spells.ts
Normal file
14
frontend/server/api/cdragon/summoner-spells.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { getSummonerSpells } from '~/server/utils/cdragon-cache'
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
try {
|
||||||
|
const summonerSpells = await getSummonerSpells()
|
||||||
|
return summonerSpells
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching summoner spells:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Failed to fetch summoner spells'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import { CDRAGON_BASE } from '~/utils/cdragon'
|
import { getChampionSummary } from '~/server/utils/cdragon-cache'
|
||||||
|
|
||||||
async function championRoutes() {
|
async function championRoutes() {
|
||||||
const championsData: Array<Champion> = await (
|
const championsData = await getChampionSummary()
|
||||||
await fetch(
|
|
||||||
CDRAGON_BASE + 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
|
|
||||||
)
|
|
||||||
).json()
|
|
||||||
|
|
||||||
const routes: Array<string> = []
|
const routes: Array<string> = []
|
||||||
for (const champion of championsData) {
|
for (const champion of championsData) {
|
||||||
|
|||||||
205
frontend/server/utils/cdragon-cache.ts
Normal file
205
frontend/server/utils/cdragon-cache.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { readFile, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
|
||||||
|
const readFileAsync = promisify(readFile)
|
||||||
|
|
||||||
|
// CDragon base URL for fallback
|
||||||
|
const CDRAGON_BASE = 'https://raw.communitydragon.org/'
|
||||||
|
|
||||||
|
// Cache directory - can be configured via environment variable
|
||||||
|
// Default to dev/cdragon for development
|
||||||
|
const getCacheDir = () => {
|
||||||
|
if (process.env.CDRAGON_CACHE_DIR) {
|
||||||
|
return process.env.CDRAGON_CACHE_DIR
|
||||||
|
}
|
||||||
|
// Default to dev/cdragon relative to project root
|
||||||
|
return join(process.cwd(), '..', 'dev', 'data', 'cdragon')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current patch from the patch.txt file or fallback to 'latest'
|
||||||
|
* Converts patch format for CDragon: "16.4.1" -> "16.4"
|
||||||
|
*/
|
||||||
|
async function getCurrentPatch(): Promise<string> {
|
||||||
|
const cacheDir = getCacheDir()
|
||||||
|
const patchFile = join(cacheDir, 'latest', 'patch.txt')
|
||||||
|
|
||||||
|
if (existsSync(patchFile)) {
|
||||||
|
const patch = await readFileAsync(patchFile, 'utf-8')
|
||||||
|
const trimmedPatch = patch.trim()
|
||||||
|
// Convert patch format for CDragon: "16.4.1" -> "16.4"
|
||||||
|
return trimmedPatch.split('.').slice(0, 2).join('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to 'latest' if no patch file exists
|
||||||
|
return 'latest'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached CDragon data types
|
||||||
|
*/
|
||||||
|
interface CDragonCacheOptions {
|
||||||
|
patch?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch data from local cache with CDragon fallback
|
||||||
|
*/
|
||||||
|
async function fetchFromCache<T>(
|
||||||
|
assetName: string,
|
||||||
|
cdragonPath: string,
|
||||||
|
options?: CDragonCacheOptions
|
||||||
|
): Promise<T> {
|
||||||
|
const cacheDir = getCacheDir()
|
||||||
|
const patch = options?.patch || (await getCurrentPatch())
|
||||||
|
const cachePath = join(cacheDir, patch, assetName)
|
||||||
|
|
||||||
|
// Try to read from cache first
|
||||||
|
if (existsSync(cachePath)) {
|
||||||
|
try {
|
||||||
|
const data = await readFileAsync(cachePath, 'utf-8')
|
||||||
|
return JSON.parse(data) as T
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading cache file ${cachePath}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to CDragon
|
||||||
|
console.log(`Cache miss for ${assetName}, fetching from CDragon...`)
|
||||||
|
const url = `${CDRAGON_BASE}${patch}/${cdragonPath}`
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${assetName} from CDragon: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get items data from cache
|
||||||
|
*/
|
||||||
|
async function getItems(patch?: string): Promise<CDragonItem[]> {
|
||||||
|
return fetchFromCache<CDragonItem[]>(
|
||||||
|
'items.json',
|
||||||
|
'plugins/rcp-be-lol-game-data/global/default/v1/items.json',
|
||||||
|
{ patch }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get perks (runes) data from cache
|
||||||
|
*/
|
||||||
|
async function getPerks(patch?: string): Promise<CDragonPerk[]> {
|
||||||
|
return fetchFromCache<CDragonPerk[]>(
|
||||||
|
'perks.json',
|
||||||
|
'plugins/rcp-be-lol-game-data/global/default/v1/perks.json',
|
||||||
|
{ patch }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get perk styles data from cache
|
||||||
|
*/
|
||||||
|
async function getPerkStyles(patch?: string): Promise<CDragonPerkStyles> {
|
||||||
|
return fetchFromCache<CDragonPerkStyles>(
|
||||||
|
'perkstyles.json',
|
||||||
|
'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json',
|
||||||
|
{ patch }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get summoner spells data from cache
|
||||||
|
*/
|
||||||
|
async function getSummonerSpells(patch?: string): Promise<CDragonSummonerSpell[]> {
|
||||||
|
return fetchFromCache<CDragonSummonerSpell[]>(
|
||||||
|
'summoner-spells.json',
|
||||||
|
'plugins/rcp-be-lol-game-data/global/default/v1/summoner-spells.json',
|
||||||
|
{ patch }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get champion summary data from cache
|
||||||
|
*/
|
||||||
|
async function getChampionSummary(patch?: string): Promise<CDragonChampionSummary[]> {
|
||||||
|
return fetchFromCache<CDragonChampionSummary[]>(
|
||||||
|
'champion-summary.json',
|
||||||
|
'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json',
|
||||||
|
{ patch }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type definitions for CDragon data
|
||||||
|
interface CDragonItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
iconPath: string
|
||||||
|
description?: string
|
||||||
|
plaintext?: string
|
||||||
|
into?: number[]
|
||||||
|
from?: number[]
|
||||||
|
gold?: {
|
||||||
|
base: number
|
||||||
|
total: number
|
||||||
|
sell: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CDragonPerk {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
iconPath: string
|
||||||
|
shortDesc?: string
|
||||||
|
longDesc?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CDragonPerkStyle {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
iconPath: string
|
||||||
|
slots: Array<{
|
||||||
|
type: string
|
||||||
|
perks: number[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CDragonPerkStyles {
|
||||||
|
styles: CDragonPerkStyle[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CDragonSummonerSpell {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
iconPath: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CDragonChampionSummary {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
alias: string
|
||||||
|
squarePortraitPath: string
|
||||||
|
roles?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
getItems,
|
||||||
|
getPerks,
|
||||||
|
getPerkStyles,
|
||||||
|
getSummonerSpells,
|
||||||
|
getChampionSummary,
|
||||||
|
getCurrentPatch,
|
||||||
|
CDRAGON_BASE
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CDragonItem,
|
||||||
|
CDragonPerk,
|
||||||
|
CDragonPerkStyle,
|
||||||
|
CDragonPerkStyles,
|
||||||
|
CDragonSummonerSpell,
|
||||||
|
CDragonChampionSummary
|
||||||
|
}
|
||||||
@@ -21,6 +21,19 @@ declare global {
|
|||||||
}
|
}
|
||||||
type Item = {
|
type Item = {
|
||||||
id: number
|
id: number
|
||||||
|
iconPath: string
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
plaintext?: string
|
||||||
|
into?: number[]
|
||||||
|
from?: number[]
|
||||||
|
price?: number
|
||||||
|
priceTotal?: number
|
||||||
|
}
|
||||||
|
type SummonerSpell = {
|
||||||
|
id: number
|
||||||
|
iconPath: string
|
||||||
|
name: string
|
||||||
}
|
}
|
||||||
type PerksResponse = {
|
type PerksResponse = {
|
||||||
data: Ref<Array<Perk>>
|
data: Ref<Array<Perk>>
|
||||||
|
|||||||
64
frontend/utils/buildHelpers.ts
Normal file
64
frontend/utils/buildHelpers.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { isEmpty } from './helpers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trims the build tree to only show the first path
|
||||||
|
* Removes alternate build paths to keep the UI clean
|
||||||
|
*/
|
||||||
|
export 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)
|
||||||
|
|
||||||
|
// Also trim grandchildren to first path only
|
||||||
|
if (builds.tree.children[0]?.children) {
|
||||||
|
builds.tree.children[0].children.splice(1, builds.tree.children[0].children.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes late game items that appear in the core build tree
|
||||||
|
* Prevents duplicate items from being shown
|
||||||
|
*/
|
||||||
|
export function trimLateGameItems(builds: Builds): void {
|
||||||
|
if (!builds?.tree || isEmpty(builds.lateGame)) return
|
||||||
|
|
||||||
|
const coreItemIds = new Set<number>()
|
||||||
|
|
||||||
|
// Collect all item IDs from the tree
|
||||||
|
function collectItemIds(tree: ItemTree): void {
|
||||||
|
if (tree.data !== undefined) {
|
||||||
|
coreItemIds.add(tree.data)
|
||||||
|
}
|
||||||
|
for (const child of tree.children || []) {
|
||||||
|
collectItemIds(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collectItemIds(builds.tree)
|
||||||
|
|
||||||
|
// Remove late game items that appear in core
|
||||||
|
builds.lateGame = builds.lateGame.filter(item => !coreItemIds.has(item.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the index of the build with the highest pickrate
|
||||||
|
*/
|
||||||
|
export function getHighestPickrateBuildIndex(runes: Array<{ pickrate: number }>): number {
|
||||||
|
if (runes.length === 0) return 0
|
||||||
|
|
||||||
|
return runes.reduce(
|
||||||
|
(maxIdx, rune, idx, arr) => (rune.pickrate > arr[maxIdx].pickrate ? idx : maxIdx),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the first core item for each build variant
|
||||||
|
*/
|
||||||
|
export function getFirstCoreItems(runes: unknown[], builds: Builds): number[] {
|
||||||
|
return runes.map(() => {
|
||||||
|
const tree = builds?.tree
|
||||||
|
return tree?.children?.[0]?.data ?? tree?.data ?? 0
|
||||||
|
})
|
||||||
|
}
|
||||||
11
frontend/utils/mockData.ts
Normal file
11
frontend/utils/mockData.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Mock data for development and fallback scenarios
|
||||||
|
* Used when API data is not available
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MOCK_SUMMONER_SPELLS = [
|
||||||
|
{ id: 4, count: 1000, pickrate: 0.45 }, // Flash
|
||||||
|
{ id: 7, count: 800, pickrate: 0.35 }, // Heal
|
||||||
|
{ id: 14, count: 600, pickrate: 0.15 }, // Ignite
|
||||||
|
{ id: 3, count: 200, pickrate: 0.05 } // Exhaust
|
||||||
|
]
|
||||||
@@ -1,16 +1,62 @@
|
|||||||
import { MongoClient } from 'mongodb'
|
import { MongoClient } from 'mongodb'
|
||||||
|
import { writeFile, mkdir } from 'fs/promises'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
// CDragon base URL for specific patch
|
||||||
|
const CDRAGON_BASE = 'https://raw.communitydragon.org/'
|
||||||
|
|
||||||
|
// Assets to cache for each patch
|
||||||
|
const CDRAGON_ASSETS = [
|
||||||
|
{
|
||||||
|
name: 'items.json',
|
||||||
|
path: 'plugins/rcp-be-lol-game-data/global/default/v1/items.json'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'perks.json',
|
||||||
|
path: 'plugins/rcp-be-lol-game-data/global/default/v1/perks.json'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'perkstyles.json',
|
||||||
|
path: 'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'summoner-spells.json',
|
||||||
|
path: 'plugins/rcp-be-lol-game-data/global/default/v1/summoner-spells.json'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'champion-summary.json',
|
||||||
|
path: 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const client = await connectToDatabase()
|
const client = await connectToDatabase()
|
||||||
|
const dbPatch = await getLatestPatchFromDatabase(client)
|
||||||
|
|
||||||
|
// In dev mode, get patch from database (the one we have match data for)
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('Development mode: downloading cache for database patch')
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
if (dbPatch) {
|
||||||
|
console.log('Latest patch in database: ' + dbPatch)
|
||||||
|
await downloadAssets(dbPatch)
|
||||||
|
} else {
|
||||||
|
console.log('No patch found in database!')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production mode: check database and update if new patch
|
||||||
const newPatch = await fetchLatestPatch()
|
const newPatch = await fetchLatestPatch()
|
||||||
|
|
||||||
console.log('Latest patch is: ' + newPatch)
|
console.log('Latest patch is: ' + newPatch)
|
||||||
|
|
||||||
const newDate = new Date()
|
const newDate = new Date()
|
||||||
|
|
||||||
if (!(await compareLatestSavedPatch(client, newPatch, newDate))) {
|
if (!(await compareLatestSavedPatch(client, newPatch, newDate))) {
|
||||||
downloadAssets()
|
await downloadAssets(newPatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.close()
|
await client.close()
|
||||||
@@ -58,4 +104,66 @@ async function compareLatestSavedPatch(client: MongoClient, newPatch: string, ne
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadAssets() {}
|
async function getLatestPatchFromDatabase(client: MongoClient): Promise<string | null> {
|
||||||
|
const database = client.db('patches')
|
||||||
|
const patches = database.collection('patches')
|
||||||
|
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
|
||||||
|
|
||||||
|
if (latestPatch == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestPatch.patch
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAssets(patch: string) {
|
||||||
|
// Convert patch format for CDragon: "16.4.1" -> "16.4"
|
||||||
|
// CDragon uses patch format without the last minor version
|
||||||
|
const cdragonPatch = patch.split('.').slice(0, 2).join('.')
|
||||||
|
console.log(`Downloading CDragon assets for patch ${cdragonPatch} (from ${patch})...`)
|
||||||
|
|
||||||
|
// Get cache directory from environment or use default
|
||||||
|
const cacheDir = process.env.CDRAGON_CACHE_DIR || '/cdragon'
|
||||||
|
const patchDir = join(cacheDir, cdragonPatch)
|
||||||
|
|
||||||
|
// Create patch directory if it doesn't exist
|
||||||
|
if (!existsSync(patchDir)) {
|
||||||
|
await mkdir(patchDir, { recursive: true })
|
||||||
|
console.log(`Created directory: ${patchDir}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download each asset
|
||||||
|
for (const asset of CDRAGON_ASSETS) {
|
||||||
|
const url = `${CDRAGON_BASE}${cdragonPatch}/${asset.path}`
|
||||||
|
const filePath = join(patchDir, asset.name)
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Downloading ${asset.name}...`)
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Failed to download ${asset.name}: ${response.status} ${response.statusText}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
await writeFile(filePath, JSON.stringify(data, null, 2))
|
||||||
|
console.log(`Saved ${asset.name} to ${filePath}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error downloading ${asset.name}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a symlink or copy to 'latest' directory for easy access
|
||||||
|
const latestDir = join(cacheDir, 'latest')
|
||||||
|
const latestFile = join(latestDir, 'patch.txt')
|
||||||
|
|
||||||
|
if (!existsSync(latestDir)) {
|
||||||
|
await mkdir(latestDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(latestFile, patch)
|
||||||
|
console.log(`Updated latest patch reference to ${patch}`)
|
||||||
|
|
||||||
|
console.log('CDragon assets download complete!')
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user