add everything
This commit is contained in:
357
js/pages/ListPacks.js
Normal file
357
js/pages/ListPacks.js
Normal file
@@ -0,0 +1,357 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user