357 lines
15 KiB
JavaScript
357 lines
15 KiB
JavaScript
import { fetchPacks, fetchPackLevels } from "../content.js";
|
|
import { getFontColour, embed } from "../util.js";
|
|
import { score, packScore } from "../score.js";
|
|
|
|
import Spinner from "../components/Spinner.js";
|
|
import LevelAuthors from "../components/List/LevelAuthors.js";
|
|
import { store } from '../main.js';
|
|
|
|
export default {
|
|
components: {
|
|
Spinner,
|
|
LevelAuthors,
|
|
},
|
|
template: `
|
|
<main v-if="loading" class="surface">
|
|
<Spinner></Spinner>
|
|
</main>
|
|
<main v-else class="pack-layout">
|
|
|
|
<!-- ══════════════════════════════
|
|
HERO BANNER
|
|
══════════════════════════════ -->
|
|
<div class="pack-hero" :style="heroStyle">
|
|
<div class="pack-hero-tint"></div>
|
|
<div class="pack-hero-collage">
|
|
<div
|
|
v-for="(level, j) in pack.levels"
|
|
:key="j"
|
|
class="pack-hero-strip"
|
|
:style="{ backgroundImage: \`url('/assets/levels/\${level}.png')\` }"
|
|
></div>
|
|
</div>
|
|
|
|
<div class="pack-hero-content">
|
|
<div class="pack-hero-meta">
|
|
<span class="pack-hero-label">LEVEL PACK</span>
|
|
<h1 class="pack-hero-name">{{ pack?.name }}</h1>
|
|
<div class="pack-hero-stats">
|
|
<span class="pack-hero-pts">{{ packPoints }} <em>pts</em></span>
|
|
<span class="pack-hero-count">{{ selectedPackLevels.length }} levels</span>
|
|
|
|
<!-- Player progress badge — only shows if leaderboard is loaded -->
|
|
<span class="pack-hero-progress-badge" v-if="playerProgress !== null">
|
|
<span class="pack-hero-progress-icon">{{ playerProgress.completed }}/{{ playerProgress.total }}</span>
|
|
<span class="pack-hero-progress-label">by {{ currentPlayerName }}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Progress bar — fills based on player completion -->
|
|
<div class="pack-hero-progress-track" v-if="playerProgress !== null">
|
|
<div
|
|
class="pack-hero-progress-fill"
|
|
:style="{
|
|
width: ((playerProgress.completed / playerProgress.total) * 100) + '%',
|
|
background: pack.colour,
|
|
boxShadow: '0 0 12px ' + pack.colour
|
|
}"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ══════════════════════════════
|
|
BODY
|
|
══════════════════════════════ -->
|
|
<div class="pack-body">
|
|
|
|
<!-- LEFT: pack picker -->
|
|
<div class="pack-picker">
|
|
<p class="pack-picker-heading">ALL PACKS</p>
|
|
<button
|
|
v-for="(p, i) in packs"
|
|
:key="i"
|
|
@click="switchLevels(i)"
|
|
class="pack-pick-btn"
|
|
:class="{ active: selected === i }"
|
|
:style="{ '--pack-colour': p.colour }"
|
|
>
|
|
<div class="pack-pick-collage">
|
|
<div
|
|
v-for="(level, j) in p.levels.slice(0, 3)"
|
|
:key="j"
|
|
class="pack-pick-strip"
|
|
:style="{ backgroundImage: \`url('/assets/levels/\${level}.png')\` }"
|
|
></div>
|
|
</div>
|
|
<div class="pack-pick-tint" :style="selected === i ? { background: p.colour + '33' } : {}"></div>
|
|
<span class="pack-pick-name">{{ p.name }}</span>
|
|
<span class="pack-pick-count">{{ p.levels.length }} levels</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- RIGHT: level list -->
|
|
<div class="pack-levels">
|
|
|
|
<!-- Sort toolbar -->
|
|
<div class="pack-sort-bar">
|
|
<!--<span class="pack-sort-label">Sort by</span>
|
|
<div class="pack-sort-tabs">
|
|
<button
|
|
class="pack-sort-tab"
|
|
:class="{ active: sortBy === 'rank' }"
|
|
@click="sortBy = 'rank'"
|
|
>Rank</button>
|
|
<button
|
|
class="pack-sort-tab"
|
|
:class="{ active: sortBy === 'points' }"
|
|
@click="sortBy = 'points'"
|
|
>Points</button>
|
|
</div>-->
|
|
|
|
<!-- player selector — only if leaderboard loaded -->
|
|
<div class="pack-player-select" v-if="leaderboardLoaded">
|
|
<span class="pack-sort-label">Preview progress for</span>
|
|
<select class="pack-player-dropdown" v-model="selectedPlayer">
|
|
<option value="">— none —</option>
|
|
<option
|
|
v-for="entry in leaderboardEntries"
|
|
:key="entry.user"
|
|
:value="entry.user"
|
|
>{{ entry.user }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Level cards -->
|
|
<transition-group name="pack-card" tag="div" class="pack-level-grid">
|
|
<div
|
|
v-for="(level, i) in sortedPackLevels"
|
|
:key="level[0]?.level?.name || i"
|
|
class="pack-level-card"
|
|
:class="{ 'pack-level-done': isCompleted(level[0]?.level?.name) }"
|
|
@click="goToLevel(level[0]?.level.name)"
|
|
>
|
|
<!-- bg thumbnail -->
|
|
<div
|
|
class="pack-level-bg"
|
|
:style="{ backgroundImage: \`url('/assets/levels/\${level[0]?.level.name}.png')\` }"
|
|
></div>
|
|
<div class="pack-level-tint" :style="{ '--pack-colour': pack.colour }"></div>
|
|
|
|
<!-- completion tick -->
|
|
<div class="pack-level-tick" v-if="isCompleted(level[0]?.level?.name)">✓</div>
|
|
|
|
<!-- rank badge -->
|
|
<div class="pack-level-rank">#{{ level[0]?.listRank }}</div>
|
|
|
|
<!-- info -->
|
|
<div class="pack-level-info">
|
|
<span class="pack-level-name">{{ level[0]?.level.name }}</span>
|
|
<div class="pack-level-bottom">
|
|
<span class="pack-level-pts">+{{ levelScore(level[0]) }} pts</span>
|
|
|
|
<!-- contribution bar -->
|
|
<div class="pack-contrib-track" :title="contribPct(level[0]) + '% of pack points'">
|
|
<div
|
|
class="pack-contrib-fill"
|
|
:style="{
|
|
width: contribPct(level[0]) + '%',
|
|
background: pack.colour,
|
|
}"
|
|
></div>
|
|
</div>
|
|
<span class="pack-contrib-pct">{{ contribPct(level[0]) }}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- arrow -->
|
|
<div class="pack-level-arrow">›</div>
|
|
</div>
|
|
</transition-group>
|
|
</div>
|
|
|
|
</div>
|
|
</main>
|
|
`,
|
|
|
|
data: () => ({
|
|
packs: [],
|
|
errors: [],
|
|
selected: 0,
|
|
selectedLevel: 0,
|
|
selectedPackLevels: [],
|
|
loading: true,
|
|
loadingPack: true,
|
|
toggledShowcase: true,
|
|
sortBy: 'rank',
|
|
selectedPlayer: '',
|
|
}),
|
|
|
|
computed: {
|
|
pack() {
|
|
return this.packs[this.selected];
|
|
},
|
|
video() {
|
|
if (!this.selectedPackLevels[this.selectedLevel][0].level.showcase) {
|
|
return embed(this.selectedPackLevels[this.selectedLevel][0].level.verification);
|
|
}
|
|
return embed(
|
|
this.toggledShowcase
|
|
? this.selectedPackLevels[this.selectedLevel][0].level.showcase
|
|
: this.selectedPackLevels[this.selectedLevel][0].level.verification,
|
|
);
|
|
},
|
|
packPoints() {
|
|
if (!Array.isArray(this.selectedPackLevels)) return 0;
|
|
const levels = this.selectedPackLevels
|
|
.map(([data]) => ({
|
|
listRank: data?.listRank,
|
|
percentToQualify: data?.level?.percentToQualify,
|
|
}))
|
|
.filter(l => l.listRank);
|
|
return Math.round(packScore(levels));
|
|
},
|
|
heroStyle() {
|
|
return { '--hero-colour': this.pack?.colour || '#222' };
|
|
},
|
|
|
|
// Sorted level list
|
|
sortedPackLevels() {
|
|
if (!this.selectedPackLevels.length) return [];
|
|
const copy = [...this.selectedPackLevels];
|
|
if (this.sortBy === 'points') {
|
|
copy.sort((a, b) => {
|
|
const pa = this.levelScore(a[0]);
|
|
const pb = this.levelScore(b[0]);
|
|
return pb - pa;
|
|
});
|
|
} else {
|
|
copy.sort((a, b) => (a[0]?.listRank ?? Infinity) - (b[0]?.listRank ?? Infinity));
|
|
}
|
|
return copy;
|
|
},
|
|
|
|
// Leaderboard integration — reads from store.leaderboard if available
|
|
// safe even if leaderboard page was never visited this session
|
|
leaderboardLoaded() {
|
|
try { return !!(store.leaderboard?.leaderboard?.length); }
|
|
catch { return false; }
|
|
},
|
|
leaderboardEntries() {
|
|
try { return store.leaderboard?.leaderboard || []; }
|
|
catch { return []; }
|
|
},
|
|
currentPlayerName() {
|
|
return this.selectedPlayer || null;
|
|
},
|
|
|
|
// Set of level names the selected player has completed or verified
|
|
playerCompletedLevels() {
|
|
if (!this.selectedPlayer || !this.leaderboardLoaded) return new Set();
|
|
const entry = this.leaderboardEntries.find(e => e.user === this.selectedPlayer);
|
|
if (!entry) return new Set();
|
|
const names = new Set();
|
|
for (const s of [...(entry.verified || []), ...(entry.completed || [])]) {
|
|
names.add(s.level);
|
|
}
|
|
return names;
|
|
},
|
|
|
|
// Progress stats for the hero bar
|
|
playerProgress() {
|
|
if (!this.selectedPlayer || !this.selectedPackLevels.length) return null;
|
|
const total = this.selectedPackLevels.length;
|
|
const completed = this.selectedPackLevels.filter(
|
|
level => this.playerCompletedLevels.has(level[0]?.level?.name)
|
|
).length;
|
|
return { completed, total };
|
|
},
|
|
},
|
|
|
|
async mounted() {
|
|
store.pack = this;
|
|
await resetPacks();
|
|
},
|
|
|
|
methods: {
|
|
async switchLevels(i) {
|
|
this.loadingPack = true;
|
|
this.selected = i;
|
|
this.selectedLevel = 0;
|
|
this.sortBy = 'rank';
|
|
this.selectedPackLevels = await fetchPackLevels(this.packs[this.selected].name);
|
|
this.errors.length = 0;
|
|
if (!this.packs) {
|
|
this.errors = ['Failed to load list. Retry in a few minutes or notify list staff.'];
|
|
} else {
|
|
this.errors.push(
|
|
...this.selectedPackLevels
|
|
.filter(([_, __, err]) => err)
|
|
.map(([_, __, err]) => `Failed to load level. (${err}.json)`)
|
|
);
|
|
}
|
|
this.loadingPack = false;
|
|
},
|
|
score,
|
|
embed,
|
|
getFontColour,
|
|
goToLevel(name) {
|
|
window.location.href = `/#/list/?level=${encodeURIComponent(name)}`;
|
|
},
|
|
|
|
// Points a single level contributes
|
|
levelScore(data) {
|
|
if (!data) return 0;
|
|
return Math.round(score(data.listRank, 100, data.level?.percentToQualify));
|
|
},
|
|
|
|
// Percentage of pack total this level contributes
|
|
contribPct(data) {
|
|
if (!data || !this.packPoints) return 0;
|
|
return Math.round((this.levelScore(data) / this.packPoints) * 100);
|
|
},
|
|
|
|
// Whether the selected player has completed a level
|
|
isCompleted(levelName) {
|
|
if (!levelName || !this.selectedPlayer) return false;
|
|
return this.playerCompletedLevels.has(levelName);
|
|
},
|
|
},
|
|
};
|
|
|
|
export async function resetPacks() {
|
|
// Guard: if the packs component isn't mounted yet, store.pack won't
|
|
// have the right shape — bail out so the component's own mounted()
|
|
// call handles initialisation instead
|
|
// bail if packs component isn't mounted yet
|
|
if (!store.pack || store.pack.loading === undefined) return;
|
|
|
|
try {
|
|
store.pack.packs = await fetchPacks();
|
|
|
|
if (!store.pack.packs) {
|
|
store.pack.errors = ['Failed to load list. Retry in a few minutes or notify list staff.'];
|
|
return;
|
|
}
|
|
|
|
store.pack.selectedPackLevels = await fetchPackLevels(
|
|
store.pack.packs[store.pack.selected].name
|
|
);
|
|
|
|
store.pack.errors.push(
|
|
...store.pack.selectedPackLevels
|
|
.filter(([_, __, err]) => err)
|
|
.map(([_, __, err]) => `Failed to load level. (${err}.json)`)
|
|
);
|
|
} catch (e) {
|
|
console.error('resetPacks failed:', e);
|
|
if (store.pack) store.pack.errors = ['Failed to load packs. Check the console for details.'];
|
|
} finally {
|
|
if (store.pack) {
|
|
store.pack.loading = false;
|
|
store.pack.loadingPack = false;
|
|
}
|
|
}
|
|
} |