add everything

This commit is contained in:
Koolant
2026-04-17 12:29:41 -04:00
commit 4c1cfe6847
437 changed files with 11939 additions and 0 deletions

357
js/pages/ListPacks.js Normal file
View 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">&#8250;</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;
}
}
}