add everything
This commit is contained in:
320
js/pages/Home.js
Normal file
320
js/pages/Home.js
Normal file
@@ -0,0 +1,320 @@
|
||||
import { fetchEditors, fetchList, fetchLeaderboard, fetchPacks } from '../content.js';
|
||||
import { store } from '../main.js';
|
||||
|
||||
export default {
|
||||
name: 'HomePage',
|
||||
|
||||
template: `
|
||||
<main v-if="loading" class="surface" style="display:flex;align-items:center;justify-content:center;min-height:100vh;">
|
||||
<p style="font-family:monospace;color:#6b7a8d;letter-spacing:0.2em;">LOADING...</p>
|
||||
</main>
|
||||
<main v-else class="home">
|
||||
|
||||
<div class="home-noise"></div>
|
||||
|
||||
<!-- ── Hero ── -->
|
||||
<section class="home-hero">
|
||||
<!--<div class="home-hero-grid"></div>-->
|
||||
<div class="home-hero-scroll">
|
||||
<div
|
||||
class="home-hero-track"
|
||||
:style="{ width: backgroundLevels.length * 300 + 'px' }"
|
||||
>
|
||||
<div
|
||||
v-for="(bg, i) in backgroundLevels"
|
||||
:key="i"
|
||||
class="home-hero-tile"
|
||||
:style="{ backgroundImage: \`url('\${bg}')\` }"
|
||||
></div>
|
||||
|
||||
<!-- duplicate for seamless loop -->
|
||||
<div
|
||||
v-for="(bg, i) in backgroundLevels"
|
||||
:key="'dup-' + i"
|
||||
class="home-hero-tile"
|
||||
:style="{ backgroundImage: \`url('\${bg}')\` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-hero-content">
|
||||
<!--<div class="home-eyebrow">
|
||||
<span class="home-dot home-dot-pulse"></span>
|
||||
<span>GEOMETRY DASH</span>
|
||||
</div>-->
|
||||
<h1 class="home-title">
|
||||
<!--<span class="home-title-line">john evil</span>
|
||||
<span class="home-title-line home-title-accent">EVIL DEMONLIST</span>
|
||||
<span class="home-title-line">by [JE] john evil</span>-->
|
||||
<img src="./tsl_logo_wName.png">
|
||||
</h1>
|
||||
<p class="home-sub">geometry dash; evil dash</p>
|
||||
<div class="home-actions">
|
||||
<router-link to="/list" class="home-btn home-btn-primary">Browse the List</router-link>
|
||||
<router-link to="/leaderboard" class="home-btn home-btn-ghost">Leaderboard</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<!--<div class="home-deco">
|
||||
<div class="home-ring home-ring-1"></div>
|
||||
<div class="home-ring home-ring-2"></div>
|
||||
<div class="home-ring home-ring-3"></div>
|
||||
</div>-->
|
||||
</section>
|
||||
|
||||
<!-- ── Stats Bar ── -->
|
||||
<section class="home-stats-bar">
|
||||
<div class="home-stats-inner">
|
||||
<div class="home-stat-item" v-for="(stat, i) in stats" :key="i">
|
||||
<span class="home-stat-value">{{ stat.value }}</span>
|
||||
<span class="home-stat-label">{{ stat.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Members Spotlight ── -->
|
||||
<section class="home-members-section">
|
||||
<div class="home-section-header">
|
||||
<h2 class="home-section-title">john evil</h2>
|
||||
</div>
|
||||
|
||||
<div class="home-spotlight" v-if="enrichedEditors.length">
|
||||
<div class="home-spotlight-bg" :style="spotlightBgStyle"></div>
|
||||
<div class="home-spotlight-tint"></div>
|
||||
|
||||
<!-- Pill nav -->
|
||||
<div class="home-pills">
|
||||
<button
|
||||
v-for="(editor, i) in enrichedEditors"
|
||||
:key="editor.name"
|
||||
class="home-pill"
|
||||
:class="{ 'home-pill-active': i === activeIdx }"
|
||||
@click="setActive(i)"
|
||||
>
|
||||
<img
|
||||
class="home-pill-icon"
|
||||
:src="'/assets/icons/' + editor.name + '.png'"
|
||||
:alt="editor.name"
|
||||
@error="onIconError"
|
||||
/>
|
||||
<span>{{ editor.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<transition name="home-card-fade" mode="out-in">
|
||||
<div class="home-card" :key="activeIdx" v-if="activeEditor">
|
||||
|
||||
<div class="home-card-left">
|
||||
<a
|
||||
v-if="activeEditor.link"
|
||||
:href="activeEditor.link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="home-yt-link"
|
||||
>
|
||||
<img
|
||||
class="home-yt-avatar"
|
||||
:src="'/assets/icons/' + activeEditor.name + '.png'"
|
||||
:alt="activeEditor.name"
|
||||
@error="onIconError"
|
||||
/>
|
||||
<span class="home-yt-badge">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14">
|
||||
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6a3 3 0 0 0-2.1 2.1C0 8.1 0 12 0 12s0 3.9.5 5.8a3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1C24 15.9 24 12 24 12s0-3.9-.5-5.8zM9.8 15.5V8.5l6.3 3.5-6.3 3.5z"/>
|
||||
</svg>
|
||||
YouTube
|
||||
</span>
|
||||
</a>
|
||||
<div v-else class="home-icon-wrap">
|
||||
<img
|
||||
:src="'/assets/icons/' + activeEditor.name + '.png'"
|
||||
:alt="activeEditor.name"
|
||||
@error="onIconError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 class="home-card-name">{{ activeEditor.name }}</h3>
|
||||
<p class="home-card-role">{{ activeEditor.role || 'List Member' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="home-card-right">
|
||||
<div class="home-cstats">
|
||||
<div class="home-cstat">
|
||||
<span class="home-cstat-val">{{ activeEditor.completions + activeEditor.verifications ?? '—' }}</span>
|
||||
<span class="home-cstat-key">Total Completions</span>
|
||||
</div>
|
||||
<div class="home-cstat">
|
||||
<span class="home-cstat-val">{{ activeEditor.verifications ?? '—' }}</span>
|
||||
<span class="home-cstat-key">First Completions</span>
|
||||
</div>
|
||||
<div class="home-cstat">
|
||||
<span class="home-cstat-val">{{ activeEditor.rank != null ? '#' + activeEditor.rank : '—' }}</span>
|
||||
<span class="home-cstat-key">Leaderboard Rank</span>
|
||||
</div>
|
||||
<div class="home-cstat" v-if="activeEditor.score != null">
|
||||
<span class="home-cstat-val">{{ activeEditor.score }}</span>
|
||||
<span class="home-cstat-key">List Score</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="home-hardest" v-if="activeEditor.hardest">
|
||||
<span class="home-hardest-label">HARDEST DEMON</span>
|
||||
<span class="home-hardest-name">{{ activeEditor.hardest.level }}</span>
|
||||
<span class="home-hardest-rank" v-if="activeEditor.hardest.rank">#{{ activeEditor.hardest.rank }} on the list</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<button class="home-nav home-nav-left" @click="prev" aria-label="Previous">←</button>
|
||||
<button class="home-nav home-nav-right" @click="next" aria-label="Next">→</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Fun Facts ── -->
|
||||
<section class="home-facts">
|
||||
<div class="home-facts-grid">
|
||||
<div class="home-fact-card" v-for="(fact, i) in funFacts" :key="i">
|
||||
<span class="home-fact-icon">{{ fact.icon }}</span>
|
||||
<p class="home-fact-text">{{ fact.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Footer CTA ── -->
|
||||
<section class="home-footer-cta">
|
||||
<h2 class="home-footer-heading">↓ view the list ↓</h2>
|
||||
<router-link to="/list" class="home-btn home-btn-primary home-btn-lg">yeah View the List</router-link>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
`,
|
||||
|
||||
data: () => ({
|
||||
store,
|
||||
rawEditors: [],
|
||||
leaderboardMap: {},
|
||||
enrichedEditors: [],
|
||||
loading: true,
|
||||
activeIdx: 0,
|
||||
stats: [
|
||||
{ value: '—', label: 'Demons Ranked' },
|
||||
{ value: '—', label: 'Total Completions' },
|
||||
{ value: '—', label: 'Active Members' },
|
||||
{ value: '—', label: 'Level Packs' },
|
||||
],
|
||||
funFacts: [
|
||||
{ icon: '🔥', text: 'i got molested when i was 7' },
|
||||
{ icon: '🤤', text: 'zorpikgmd is our one and only goongod and we are all his bellyslaves' },
|
||||
{ icon: '💀', text: 'we are evil. john ai vs jew bot incident 3/23/2026 never forget ✊' },
|
||||
],
|
||||
backgroundLevels: [],
|
||||
}),
|
||||
|
||||
watch: {
|
||||
'store.listType'() {
|
||||
resetHome(this);
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
activeEditor() {
|
||||
return this.enrichedEditors[this.activeIdx] || null;
|
||||
},
|
||||
spotlightBgStyle() {
|
||||
const ed = this.activeEditor;
|
||||
if (!ed || !ed.hardest) return {};
|
||||
const p = ed.hardest.path
|
||||
? `/assets/levels/${ed.hardest.path}.png`
|
||||
: '/assets/levels/default.png';
|
||||
return { backgroundImage: `url('${p}')` };
|
||||
},
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
await resetHome(this);
|
||||
},
|
||||
|
||||
methods: {
|
||||
setActive(i) { this.activeIdx = i; },
|
||||
next() { this.activeIdx = (this.activeIdx + 1) % this.enrichedEditors.length; },
|
||||
prev() { this.activeIdx = (this.activeIdx - 1 + this.enrichedEditors.length) % this.enrichedEditors.length; },
|
||||
onIconError(e) { e.target.src = '/assets/icons/default.png'; },
|
||||
},
|
||||
};
|
||||
|
||||
export async function resetHome(ctx) {
|
||||
|
||||
console.log("evil");
|
||||
ctx.loading = true;
|
||||
|
||||
try {
|
||||
const [rawEditors, listData, [leaderboard]] = await Promise.all([
|
||||
fetchEditors(),
|
||||
fetchList(),
|
||||
fetchLeaderboard(),
|
||||
]);
|
||||
|
||||
ctx.rawEditors = (rawEditors || []).map((e) =>
|
||||
typeof e === 'string' ? { name: e } : e
|
||||
);
|
||||
|
||||
// Build leaderboard lookup keyed by lowercase name
|
||||
const leaderboardMap = {};
|
||||
if (leaderboard && Array.isArray(leaderboard)) {
|
||||
leaderboard.forEach((entry, idx) => {
|
||||
leaderboardMap[entry.user.toLowerCase()] = { ...entry, rank: idx + 1 };
|
||||
});
|
||||
}
|
||||
ctx.leaderboardMap = leaderboardMap;
|
||||
|
||||
// Stats from list
|
||||
if (listData) {
|
||||
const valid = listData.filter(([, rank, level]) => rank !== null && level !== null);
|
||||
const totalRecords = valid.reduce((s, [,, lv]) => s + (lv?.records?.length ?? 0), 0);
|
||||
ctx.stats[0].value = valid.length;
|
||||
ctx.stats[1].value = totalRecords + valid.length;
|
||||
ctx.stats[2].value = ctx.rawEditors.length;
|
||||
}
|
||||
|
||||
if (listData) {
|
||||
const valid = listData.filter(([, rank, level]) => rank !== null && level !== null);
|
||||
|
||||
// pick random levels (like 20–30 so it looks full)
|
||||
const shuffled = valid.sort(() => 0.5 - Math.random());
|
||||
|
||||
ctx.backgroundLevels = shuffled.slice(0, 25).map(([, , lv]) => {
|
||||
return `/assets/levels/${lv.path || lv.name}.png`;
|
||||
});
|
||||
}
|
||||
|
||||
// Enrich each editor from leaderboard
|
||||
ctx.enrichedEditors = ctx.rawEditors.map((editor) => {
|
||||
const lb = leaderboardMap[editor.name.toLowerCase()];
|
||||
if (!lb) return { ...editor };
|
||||
|
||||
const completions = lb.completed?.length ?? 0;
|
||||
const verifications = lb.verified?.length ?? 0;
|
||||
|
||||
const allFinished = [...(lb.verified || []), ...(lb.completed || [])];
|
||||
let hardest = null;
|
||||
if (allFinished.length > 0) {
|
||||
const best = allFinished.reduce((p, c) => c.rank < p.rank ? c : p);
|
||||
hardest = { level: best.level, rank: best.rank, path: best.path, link: best.link };
|
||||
}
|
||||
|
||||
return { ...editor, completions, verifications, rank: lb.rank, score: lb.total, hardest };
|
||||
});
|
||||
|
||||
// Pack count
|
||||
try {
|
||||
const packs = await fetchPacks();
|
||||
if (packs) ctx.stats[3].value = packs.length;
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
} catch (err) {
|
||||
console.error('HomePage: failed to load data:', err);
|
||||
} finally {
|
||||
ctx.loading = false;
|
||||
}
|
||||
}
|
||||
309
js/pages/Leaderboard.js
Normal file
309
js/pages/Leaderboard.js
Normal file
@@ -0,0 +1,309 @@
|
||||
import { fetchLeaderboard, fetchPackLevels } from "../content.js";
|
||||
import { localize, getFontColour, getLeaderboardLevelThumbnail } from "../util.js";
|
||||
import { packScore } from "../score.js";
|
||||
|
||||
import Spinner from "../components/Spinner.js";
|
||||
import { store } from '../main.js';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Spinner,
|
||||
},
|
||||
data: () => ({
|
||||
leaderboard: [],
|
||||
profiles: [],
|
||||
loading: true,
|
||||
selected: 0,
|
||||
err: [],
|
||||
}),
|
||||
template: `
|
||||
<main v-if="loading" class="surface">
|
||||
<Spinner></Spinner>
|
||||
</main>
|
||||
<main v-else class="page-leaderboard-container">
|
||||
<div class="page-leaderboard">
|
||||
<div class="error-container">
|
||||
<p class="error" v-if="err.length > 0">
|
||||
Leaderboard may be incorrect, as the following levels could not be loaded: {{ err.join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="board-container surface">
|
||||
<table class="board">
|
||||
<tr v-for="(ientry, i) in leaderboard">
|
||||
<td class="rank">
|
||||
<p class="type-label-lg">#{{ i + 1 }}</p>
|
||||
</td>
|
||||
<td class="rank-image">
|
||||
<img v-if="i + 1 == 1" class="trophy" src="assets/Top1Trophy.png" />
|
||||
<img v-if="i + 1 == 2" class="trophy" src="assets/Top2Trophy.png" />
|
||||
<img v-if="i + 1 == 3" class="trophy" src="assets/Top3Trophy.png" />
|
||||
</td>
|
||||
<td class="total">
|
||||
<p class="type-label-lg">{{ localize(ientry.total) }}</p>
|
||||
</td>
|
||||
<td class="userIcon">
|
||||
<img class="ico" :src="\`/assets/icons/\${ientry.user}.png\`" :alt="ientry.user">
|
||||
</td>
|
||||
<td class="user" :class="{ 'active': selected == i }">
|
||||
<button @click="selected = i">
|
||||
<span class="type-label-lg">{{ ientry.user }}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="player-container surface">
|
||||
<div class="player">
|
||||
<h1>#{{ selected + 1 }} {{ entry.user }}</h1>
|
||||
<h3>{{ entry.total }} points</h3>
|
||||
<p class="extra_points_info" v-if="entry.packPoints > 0">+{{ entry.total - entry.packPoints }} from levels</p>
|
||||
<p class="extra_points_info" v-if="entry.packPoints > 0">+{{ entry.packPoints }} from packs</p>
|
||||
|
||||
<h3 v-if="entry.verified.length > 0 || entry.completed.length > 0">
|
||||
Hardest: {{ [...entry.verified, ...entry.completed].reduce((min, current) => current.rank < min.rank ? current : min).level }}
|
||||
</h3>
|
||||
|
||||
<!-- ── Score breakdown ── -->
|
||||
<div class="lb-breakdown" v-if="breakdown.total > 0">
|
||||
|
||||
<!-- Donut chart -->
|
||||
<div class="lb-donut-wrap">
|
||||
<svg class="lb-donut" viewBox="0 0 120 120">
|
||||
<!-- background track -->
|
||||
<circle
|
||||
cx="60" cy="60" r="48"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.06)"
|
||||
stroke-width="14"
|
||||
/>
|
||||
<!-- verified segment -->
|
||||
<circle
|
||||
v-if="breakdown.verified > 0"
|
||||
cx="60" cy="60" r="48"
|
||||
fill="none"
|
||||
stroke="#e8b84b"
|
||||
stroke-width="14"
|
||||
stroke-linecap="butt"
|
||||
:stroke-dasharray="donutDash(breakdown.verified, breakdown.total)"
|
||||
:stroke-dashoffset="donutOffset(0, breakdown.total)"
|
||||
transform="rotate(-90 60 60)"
|
||||
/>
|
||||
<!-- completed segment -->
|
||||
<circle
|
||||
v-if="breakdown.completed > 0"
|
||||
cx="60" cy="60" r="48"
|
||||
fill="none"
|
||||
stroke="#4b9de8"
|
||||
stroke-width="14"
|
||||
stroke-linecap="butt"
|
||||
:stroke-dasharray="donutDash(breakdown.completed, breakdown.total)"
|
||||
:stroke-dashoffset="donutOffset(breakdown.verified, breakdown.total)"
|
||||
transform="rotate(-90 60 60)"
|
||||
/>
|
||||
<!-- progressed segment -->
|
||||
<circle
|
||||
v-if="breakdown.progressed > 0"
|
||||
cx="60" cy="60" r="48"
|
||||
fill="none"
|
||||
stroke="#4be88a"
|
||||
stroke-width="14"
|
||||
stroke-linecap="butt"
|
||||
:stroke-dasharray="donutDash(breakdown.progressed, breakdown.total)"
|
||||
:stroke-dashoffset="donutOffset(breakdown.verified + breakdown.completed, breakdown.total)"
|
||||
transform="rotate(-90 60 60)"
|
||||
/>
|
||||
<!-- pack segment -->
|
||||
<circle
|
||||
v-if="breakdown.packs > 0"
|
||||
cx="60" cy="60" r="48"
|
||||
fill="none"
|
||||
stroke="#e84b9d"
|
||||
stroke-width="14"
|
||||
stroke-linecap="butt"
|
||||
:stroke-dasharray="donutDash(breakdown.packs, breakdown.total)"
|
||||
:stroke-dashoffset="donutOffset(breakdown.verified + breakdown.completed + breakdown.progressed, breakdown.total)"
|
||||
transform="rotate(-90 60 60)"
|
||||
/>
|
||||
<!-- centre label -->
|
||||
<text x="60" y="55" text-anchor="middle" class="lb-donut-total">{{ localize(breakdown.total) }}</text>
|
||||
<text x="60" y="70" text-anchor="middle" class="lb-donut-label">pts</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Bar breakdown -->
|
||||
<div class="lb-bars">
|
||||
<div class="lb-bar-row" v-if="breakdown.verified > 0">
|
||||
<span class="lb-bar-dot" style="background:#e8b84b"></span>
|
||||
<span class="lb-bar-cat">Verified</span>
|
||||
<div class="lb-bar-track">
|
||||
<div class="lb-bar-fill" style="background:#e8b84b" :style="{ width: pct(breakdown.verified, breakdown.total) + '%' }"></div>
|
||||
</div>
|
||||
<span class="lb-bar-val">{{ localize(breakdown.verified) }}</span>
|
||||
</div>
|
||||
<div class="lb-bar-row" v-if="breakdown.completed > 0">
|
||||
<span class="lb-bar-dot" style="background:#4b9de8"></span>
|
||||
<span class="lb-bar-cat">Completed</span>
|
||||
<div class="lb-bar-track">
|
||||
<div class="lb-bar-fill" style="background:#4b9de8" :style="{ width: pct(breakdown.completed, breakdown.total) + '%' }"></div>
|
||||
</div>
|
||||
<span class="lb-bar-val">{{ localize(breakdown.completed) }}</span>
|
||||
</div>
|
||||
<div class="lb-bar-row" v-if="breakdown.progressed > 0">
|
||||
<span class="lb-bar-dot" style="background:#4be88a"></span>
|
||||
<span class="lb-bar-cat">Progressed</span>
|
||||
<div class="lb-bar-track">
|
||||
<div class="lb-bar-fill" style="background:#4be88a" :style="{ width: pct(breakdown.progressed, breakdown.total) + '%' }"></div>
|
||||
</div>
|
||||
<span class="lb-bar-val">{{ localize(breakdown.progressed) }}</span>
|
||||
</div>
|
||||
<div class="lb-bar-row" v-if="breakdown.packs > 0">
|
||||
<span class="lb-bar-dot" style="background:#e84b9d"></span>
|
||||
<span class="lb-bar-cat">Packs</span>
|
||||
<div class="lb-bar-track">
|
||||
<div class="lb-bar-fill" style="background:#e84b9d" :style="{ width: pct(breakdown.packs, breakdown.total) + '%' }"></div>
|
||||
</div>
|
||||
<span class="lb-bar-val">{{ localize(breakdown.packs) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── End score breakdown ── -->
|
||||
|
||||
<div class="packs" v-if="entry.packs.length > 0">
|
||||
<div v-for="pack in entry.packs" class="tag" :style="{background:pack.colour, color:getFontColour(pack.colour)}">
|
||||
{{pack.name}}
|
||||
</div>
|
||||
</div>
|
||||
<h2 v-if="entry.verified.length > 0">Verified ({{ entry.verified.length}})</h2>
|
||||
<table v-if="entry.verified.length > 0" class="table">
|
||||
<tr v-for="score in entry.verified">
|
||||
<td class="rank">
|
||||
<p>#{{ score.rank }}</p>
|
||||
</td>
|
||||
<td class="level" :style="getLeaderboardLevelThumbnail(score.level)">
|
||||
<a class="type-label-lg" target="_blank" :href="score.link">{{ score.level }}</a>
|
||||
<p class="type-label-sm">+{{ localize(Math.round(score.score)) }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h2 v-if="entry.completed.length > 0">Completed ({{ entry.completed.length }})</h2>
|
||||
<table v-if="entry.completed.length > 0" class="table">
|
||||
<tr v-for="score in entry.completed">
|
||||
<td class="rank">
|
||||
<p>#{{ score.rank }}</p>
|
||||
</td>
|
||||
<td class="level" :style="getLeaderboardLevelThumbnail(score.level)">
|
||||
<a class="type-label-lg" target="_blank" :href="score.link">{{ score.level }}</a>
|
||||
<p class="type-label-sm">+{{ localize(Math.round(score.score)) }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h2 v-if="entry.progressed.length > 0">Progressed ({{entry.progressed.length}})</h2>
|
||||
<table v-if="entry.progressed.length > 0" class="table">
|
||||
<tr v-for="score in entry.progressed">
|
||||
<td class="rank">
|
||||
<p>#{{ score.rank }}</p>
|
||||
</td>
|
||||
<td class="level" :style="getLeaderboardLevelThumbnail(score.level)">
|
||||
<a class="type-label-lg" target="_blank" :href="score.link">{{ score.level }}</a>
|
||||
<p class="type-label-sm">+{{ localize(Math.round(score.score)) }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
`,
|
||||
computed: {
|
||||
entry() {
|
||||
return this.leaderboard[this.selected];
|
||||
},
|
||||
// Sums each category's scores from the raw arrays
|
||||
breakdown() {
|
||||
const e = this.entry;
|
||||
if (!e) return { verified: 0, completed: 0, progressed: 0, packs: 0, total: 0 };
|
||||
|
||||
const sum = arr => (arr || []).reduce((acc, s) => acc + Math.round(s.score || 0), 0);
|
||||
|
||||
const verified = Math.round(sum(e.verified));
|
||||
const completed = Math.round(sum(e.completed));
|
||||
const progressed = Math.round(sum(e.progressed));
|
||||
const packs = e.packPoints || 0;
|
||||
const total = verified + completed + progressed + packs;
|
||||
|
||||
return { verified, completed, progressed, packs, total };
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
localize,
|
||||
getFontColour,
|
||||
getLeaderboardLevelThumbnail,
|
||||
|
||||
// Returns stroke-dasharray string for a donut segment
|
||||
// circumference = 2 * π * r = 2 * π * 48 ≈ 301.59
|
||||
donutDash(value, total) {
|
||||
const circ = 2 * Math.PI * 48;
|
||||
const filled = (value / total) * circ;
|
||||
return `${filled} ${circ - filled}`;
|
||||
},
|
||||
|
||||
// Returns stroke-dashoffset to rotate a segment into position
|
||||
// offset starts at 0 (top of circle after the -90deg transform)
|
||||
// and advances by each preceding segment's arc length
|
||||
donutOffset(preceding, total) {
|
||||
const circ = 2 * Math.PI * 48;
|
||||
// dashoffset is negative of how far around the circle we've gone
|
||||
return -((preceding / total) * circ);
|
||||
},
|
||||
|
||||
// Percentage of total, clamped 0–100
|
||||
pct(value, total) {
|
||||
if (!total) return 0;
|
||||
return Math.min(100, Math.round((value / total) * 100));
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
store.leaderboard = this;
|
||||
await resetLeaderboard();
|
||||
},
|
||||
};
|
||||
|
||||
export async function resetLeaderboard() {
|
||||
store.leaderboard.loading = true;
|
||||
|
||||
const [leaderboard, err] = await fetchLeaderboard();
|
||||
|
||||
// calculate pack points
|
||||
for (const entry of leaderboard) {
|
||||
let packPoints = 0;
|
||||
|
||||
if (entry.packs && entry.packs.length) {
|
||||
for (const pack of entry.packs) {
|
||||
const packLevels = await fetchPackLevels(pack.name);
|
||||
const levels = packLevels
|
||||
.map(l => ({
|
||||
listRank: l[0]?.listRank,
|
||||
percentToQualify: l[0]?.level?.percentToQualify ?? 0
|
||||
}))
|
||||
.filter(l => l.listRank);
|
||||
packPoints += packScore(levels);
|
||||
}
|
||||
}
|
||||
|
||||
entry.total += Math.round(packPoints);
|
||||
entry.packPoints = Math.round(packPoints);
|
||||
}
|
||||
|
||||
// pack points resort
|
||||
leaderboard.sort((a, b) => b.total - a.total);
|
||||
|
||||
for (const entry of leaderboard) {
|
||||
entry.verified = entry.verified.map(s => ({ ...s, score: Math.round(s.score) }));
|
||||
entry.completed = entry.completed.map(s => ({ ...s, score: Math.round(s.score) }));
|
||||
entry.progressed = entry.progressed.map(s => ({ ...s, score: Math.round(s.score) }));
|
||||
}
|
||||
|
||||
store.leaderboard.leaderboard = leaderboard;
|
||||
store.leaderboard.err = err;
|
||||
store.leaderboard.loading = false;
|
||||
}
|
||||
679
js/pages/List.js
Normal file
679
js/pages/List.js
Normal file
@@ -0,0 +1,679 @@
|
||||
import { store } from '../main.js';
|
||||
import { embed, getFontColour, getLevelThumbnail } from '../util.js';
|
||||
import { score } from '../score.js';
|
||||
import { fetchEditors, fetchList } from '../content.js';
|
||||
|
||||
import Spinner from '../components/Spinner.js';
|
||||
import LevelAuthors from '../components/List/LevelAuthors.js';
|
||||
|
||||
const roleIconMap = {
|
||||
owner: 'crown',
|
||||
member: 'cube',
|
||||
admin: 'user-gear',
|
||||
kreo: 'kreo',
|
||||
seniormod: 'user-shield',
|
||||
mod: 'user-lock',
|
||||
trial: 'user-check',
|
||||
dev: 'code'
|
||||
};
|
||||
|
||||
// Extract a YouTube video ID from any YT URL and return the thumbnail URL
|
||||
function ytThumb(url) {
|
||||
if (!url) return null;
|
||||
const m = url.match(/(?:youtu\.be\/|v=|\/embed\/)([A-Za-z0-9_-]{11})/);
|
||||
return m ? `https://img.youtube.com/vi/${m[1]}/mqdefault.jpg` : null;
|
||||
}
|
||||
|
||||
export default {
|
||||
components: { Spinner, LevelAuthors },
|
||||
template: `
|
||||
<main v-if="loading" class="surface">
|
||||
<Spinner></Spinner>
|
||||
</main>
|
||||
|
||||
<!-- ══════════════════════════════════════════
|
||||
POINTERCRATE LAYOUT (store.pointercrateLayout = false)
|
||||
Identical to the original layout
|
||||
══════════════════════════════════════════ -->
|
||||
<main v-else-if="!store.pointercrateLayout" class="page-list">
|
||||
<div class="list-container surface">
|
||||
<div class="list-toolbar">
|
||||
<input
|
||||
id="levelSearch"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
placeholder="Search levels..."
|
||||
aria-label="Search levels"
|
||||
autocomplete="off"
|
||||
style="flex:1; padding:10px 12px; border-radius:8px; border:none; background:#2a2a2a; color:#fff;"
|
||||
/>
|
||||
<div style="position:relative;">
|
||||
<button @click="menuOpen = !menuOpen" style="padding:8px 14px; border-radius:8px; display:flex; align-items:center; gap:6px; background:#2a2a2a; color:#fff; border:none; cursor:pointer;">
|
||||
<span style="font-size:14px;">Sort & Filter</span>
|
||||
<span style="font-size:10px;">{{ menuOpen ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
<div v-if="menuOpen" style="position:absolute; right:0; top:calc(100% + 6px); width:280px; background:#1e1e1e; border:1px solid #444; border-radius:10px; padding:1rem; z-index:100;">
|
||||
<p style="font-size:12px; color:#888; margin:0 0 8px; text-transform:uppercase;">Sort by</p>
|
||||
<div style="display:flex; flex-direction:column; gap:6px; margin-bottom:1rem;">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:14px; color:#fff; cursor:pointer;">
|
||||
<input type="radio" v-model="pendingSortBy" value="rank" /> Rank
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:14px; color:#fff; cursor:pointer;">
|
||||
<input type="radio" v-model="pendingSortBy" value="enjoyment" /> Enjoyment
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:14px; color:#fff; cursor:pointer;">
|
||||
<input type="radio" v-model="pendingSortBy" value="name" /> Name (A–Z)
|
||||
</label>
|
||||
</div>
|
||||
<div style="border-top:1px solid #333; padding-top:1rem; margin-bottom:1rem;">
|
||||
<p style="font-size:12px; color:#888; margin:0 0 8px; text-transform:uppercase;">Filter</p>
|
||||
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:14px; color:#fff; cursor:pointer;">
|
||||
<input type="checkbox" v-model="pendingRatedOnly" /> Rated levels only
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:14px; color:#fff; cursor:pointer;">
|
||||
<input type="checkbox" v-model="pendingUnratedOnly" /> Unrated levels only
|
||||
</label>
|
||||
<div style="margin-top:4px;">
|
||||
<label style="font-size:13px; color:#888; display:block; margin-bottom:4px;">Min enjoyment: {{ pendingMinEnjoyment }}</label>
|
||||
<input type="range" min="0" max="100" v-model.number="pendingMinEnjoyment" style="width:100%;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="applyFilters" style="width:100%; padding:9px; border-radius:8px; background:#3a6ee8; color:#fff; border:none; font-size:14px; font-weight:500; cursor:pointer;">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="list-separator"><i>The List</i></h2>
|
||||
<table class="list" v-if="list">
|
||||
<tr v-for="([err, rank, level], i) in filteredList">
|
||||
<td class="rank">
|
||||
<p v-if="rank != null && rank <= 150" class="type-label-lg">#{{ rank }}</p>
|
||||
<p v-else-if="rank == null && showUnverified" class="type-label-lg">—</p>
|
||||
</td>
|
||||
<td class="rank-image">
|
||||
<img v-if="rank == 1" class="rank-trophy" src="assets/Top1Trophy.png" />
|
||||
<img v-if="rank == 2" class="rank-trophy" src="assets/Top2Trophy.png" />
|
||||
<img v-if="rank == 3" class="rank-trophy" src="assets/Top3Trophy.png" />
|
||||
<img v-if="rank >= 4" class="rank-trophy" src="assets/NoTrophy.png" />
|
||||
</td>
|
||||
<td class="level" :class="{ 'active': level?.id === this.level?.id, 'error': !level }">
|
||||
<button
|
||||
@click="selectLevel(level)"
|
||||
@mouseenter="previewBackground(level)"
|
||||
@mouseleave="previewBackground(this.level)"
|
||||
v-if="rank != null && rank <= 150"
|
||||
:style="getLevelThumbnail(level.name)"
|
||||
>
|
||||
<span class="type-label-lg" v-html="highlight(level?.name || '')"></span>
|
||||
<span class="type-label-sm" v-html="highlight(level?.author || '')"></span>
|
||||
<span v-if="aredlRanks[level.id]" class="type-label-sm">Aredl #{{ aredlRanks[level.id] ?? "Loading..." }}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h2 class="list-separator" v-if="list[150] != null"><i>Legacy List</i></h2>
|
||||
<table class="list" v-if="list">
|
||||
<tr v-for="([err, rank, level], i) in filteredList">
|
||||
<td class="rank">
|
||||
<p v-if="rank != null && rank > 150" class="type-label-lg">#{{ rank }}</p>
|
||||
</td>
|
||||
<td class="rank-image">
|
||||
<img v-if="rank >= 151" class="rank-trophy" src="assets/NoTrophy.png" />
|
||||
</td>
|
||||
<td class="level" :class="{ 'active': level?.id === this.level?.id, 'error': !level }">
|
||||
<button
|
||||
@click="selectLevel(level)"
|
||||
@mouseenter="previewBackground(level)"
|
||||
v-if="rank != null && rank > 150"
|
||||
:style="getLevelThumbnail(level.name)"
|
||||
>
|
||||
<span class="type-label-lg" v-html="highlight(level?.name || '')"></span>
|
||||
<span class="type-label-sm" v-html="highlight(level?.verifier || '')"></span>
|
||||
<span v-if="aredlRanks[level.id]" class="type-label-sm">Aredl #{{ aredlRanks[level.id] ?? "Loading..." }}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="level-container surface">
|
||||
<div class="level" v-if="level">
|
||||
<h1>{{ level.name }}</h1>
|
||||
<p><i>"{{level.gdleveldescription || "(No description provided)"}}"</i></p>
|
||||
<LevelAuthors :author="level.author" :creators="level.creators" :verifier="level.verifier"></LevelAuthors>
|
||||
<p>{{level.description || "No Description Provided."}}</p>
|
||||
<div class="packs" v-if="level.packs.length > 0">
|
||||
<div v-for="pack in level.packs" class="tag" :style="{background:pack.colour}">
|
||||
<p :style="{color:getFontColour(pack.colour)}">{{pack.name}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="level.showcase" class="tabs">
|
||||
<button class="tab type-label-lg" :class="{selected: !toggledShowcase}" @click="toggledShowcase = false">
|
||||
<span class="type-label-lg">Verification</span>
|
||||
</button>
|
||||
<button class="tab" :class="{selected: toggledShowcase}" @click="toggledShowcase = true">
|
||||
<span class="type-label-lg">Showcase</span>
|
||||
</button>
|
||||
</div>
|
||||
<iframe class="video" id="videoframe" :src="video" frameborder="0"></iframe>
|
||||
<ul class="stats">
|
||||
<li>
|
||||
<div class="type-title-sm">Points when completed</div>
|
||||
<p>{{ score(level.rank, 100, level.percentToQualify) }}</p>
|
||||
</li>
|
||||
<li>
|
||||
<div class="type-title-sm">ID</div>
|
||||
<p>{{ level.id }}</p>
|
||||
</li>
|
||||
<li class="enjoyment-stat" :style="{ '--enjoyment': level.enjoyment ?? 0 }">
|
||||
<div class="type-title-sm">Enjoyment</div>
|
||||
<p>{{ level.enjoyment ?? '—' }}/100</p>
|
||||
<div class="enjoyment-bar-track">
|
||||
<div
|
||||
class="enjoyment-bar-fill"
|
||||
:style="{ width: (level.enjoyment ?? 0) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else class="level" style="height: 100%; justify-content: center; align-items: center;">
|
||||
<p>(ノಠ益ಠ)ノ彡┻━┻</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-container surface">
|
||||
<div class="meta">
|
||||
<div class="errors" v-show="errors.length > 0">
|
||||
<p class="error" v-for="error of errors">{{ error }}</p>
|
||||
</div>
|
||||
<h2>Records</h2>
|
||||
<p v-if="level.rank == null"><strong>{{ level.percentToQualify }}%</strong> or better to qualify</p>
|
||||
<p v-else-if="level.rank <= 75"><strong>{{ level.percentToQualify }}%</strong> or better to qualify</p>
|
||||
<p v-else><strong>100%</strong> to qualify</p>
|
||||
<table class="records">
|
||||
<tr v-for="record in level.records" class="record">
|
||||
<td class="percent">
|
||||
<p>{{ record.percent }}%</p>
|
||||
</td>
|
||||
<td class="userIcon">
|
||||
<img class="ico" :src="\`/assets/icons/\${record.user}.png\`" :alt="record.user">
|
||||
</td>
|
||||
<td class="user">
|
||||
<a :href="record.link" target="_blank" class="type-label-lg">{{ record.user }}</a>
|
||||
</td>
|
||||
<td class="mobile">
|
||||
<img v-if="record.mobile" :src="\`/assets/phone-landscape\${store?.dark ? '-dark' : ''}.svg\`" alt="Mobile">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ══════════════════════════════════════════
|
||||
CARD LAYOUT (store.pointercrateLayout = true)
|
||||
Centered list of large level cards + slide-in detail panel
|
||||
══════════════════════════════════════════ -->
|
||||
<main v-else class="page-list-cards">
|
||||
|
||||
<!-- Detail panel overlay -->
|
||||
<transition name="cl-panel-slide">
|
||||
<div v-if="cardSelected" class="cl-panel-backdrop" @click.self="cardSelected = null">
|
||||
<div class="cl-panel surface">
|
||||
<button class="cl-panel-close" @click="cardSelected = null" aria-label="Close">✕</button>
|
||||
|
||||
<div class="cl-panel-hero" :style="cardSelected.name ? { backgroundImage: \`url('/assets/levels/\${cardSelected.name}.png')\` } : {}">
|
||||
<div class="cl-panel-hero-tint"></div>
|
||||
<div class="cl-panel-hero-info">
|
||||
<span class="cl-panel-rank" v-if="cardSelected.rank">#{{ cardSelected.rank }}</span>
|
||||
<h1 class="cl-panel-title">{{ cardSelected.name }}</h1>
|
||||
<LevelAuthors :author="cardSelected.author" :creators="cardSelected.creators" :verifier="cardSelected.verifier"></LevelAuthors>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cl-panel-body">
|
||||
<p v-if="cardSelected.gdleveldescription"><i>"{{ cardSelected.gdleveldescription }}"</i></p>
|
||||
<p v-else><i>(No description provided)</i></p>
|
||||
|
||||
<p v-if="cardSelected.description">{{ cardSelected.description }}</p>
|
||||
|
||||
<div class="packs" v-if="cardSelected.packs && cardSelected.packs.length > 0">
|
||||
<div v-for="pack in cardSelected.packs" class="tag" :style="{background:pack.colour}">
|
||||
<p :style="{color:getFontColour(pack.colour)}">{{pack.name}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="cardSelected.showcase" class="tabs">
|
||||
<button class="tab type-label-lg" :class="{selected: !toggledShowcase}" @click="toggledShowcase = false">
|
||||
<span class="type-label-lg">Verification</span>
|
||||
</button>
|
||||
<button class="tab" :class="{selected: toggledShowcase}" @click="toggledShowcase = true">
|
||||
<span class="type-label-lg">Showcase</span>
|
||||
</button>
|
||||
</div>
|
||||
<iframe class="video" :src="cardVideo" frameborder="0"></iframe>
|
||||
|
||||
<ul class="stats">
|
||||
<li>
|
||||
<div class="type-title-sm">Points when completed</div>
|
||||
<p>{{ score(cardSelected.rank, 100, cardSelected.percentToQualify) }}</p>
|
||||
</li>
|
||||
<li>
|
||||
<div class="type-title-sm">ID</div>
|
||||
<p>{{ cardSelected.id }}</p>
|
||||
</li>
|
||||
<li class="enjoyment-stat" :style="{ '--enjoyment': cardSelected.enjoyment ?? 0 }">
|
||||
<div class="type-title-sm">Enjoyment</div>
|
||||
<p>{{ cardSelected.enjoyment ?? '—' }}/100</p>
|
||||
<div class="enjoyment-bar-track">
|
||||
<div
|
||||
class="enjoyment-bar-fill"
|
||||
:style="{ width: (cardSelected.enjoyment ?? 0) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Records</h2>
|
||||
<p v-if="cardSelected.rank == null"><strong>{{ cardSelected.percentToQualify }}%</strong> or better to qualify</p>
|
||||
<p v-else-if="cardSelected.rank <= 75"><strong>{{ cardSelected.percentToQualify }}%</strong> or better to qualify</p>
|
||||
<p v-else><strong>100%</strong> to qualify</p>
|
||||
<table class="records">
|
||||
<tr v-for="record in cardSelected.records" class="record">
|
||||
<td class="percent"><p>{{ record.percent }}%</p></td>
|
||||
<td class="userIcon">
|
||||
<img class="ico" :src="\`/assets/icons/\${record.user}.png\`" :alt="record.user">
|
||||
</td>
|
||||
<td class="user">
|
||||
<a :href="record.link" target="_blank" class="type-label-lg">{{ record.user }}</a>
|
||||
</td>
|
||||
<td class="mobile">
|
||||
<img v-if="record.mobile" :src="\`/assets/phone-landscape\${store?.dark ? '-dark' : ''}.svg\`" alt="Mobile">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Search + filter bar -->
|
||||
<div class="cl-toolbar">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
placeholder="Search levels..."
|
||||
aria-label="Search levels"
|
||||
autocomplete="off"
|
||||
class="cl-search"
|
||||
/>
|
||||
<button ref="filterBtn" @click="toggleMenu" class="cl-filter-btn">
|
||||
<span>Sort & Filter</span>
|
||||
<span style="font-size:10px;">{{ menuOpen ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown teleported to body so no parent overflow/stacking context can trap it -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="menuOpen"
|
||||
class="cl-filter-dropdown"
|
||||
:style="dropdownStyle"
|
||||
>
|
||||
<p class="cl-filter-heading">Sort by</p>
|
||||
<div style="display:flex; flex-direction:column; gap:6px; margin-bottom:1rem;">
|
||||
<label class="cl-filter-label"><input type="radio" v-model="pendingSortBy" value="rank" /> Rank</label>
|
||||
<label class="cl-filter-label"><input type="radio" v-model="pendingSortBy" value="enjoyment" /> Enjoyment</label>
|
||||
<label class="cl-filter-label"><input type="radio" v-model="pendingSortBy" value="name" /> Name (A–Z)</label>
|
||||
</div>
|
||||
<div style="border-top:1px solid #333; padding-top:1rem; margin-bottom:1rem;">
|
||||
<p class="cl-filter-heading">Filter</p>
|
||||
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||
<label class="cl-filter-label"><input type="checkbox" v-model="pendingRatedOnly" /> Rated levels only</label>
|
||||
<label class="cl-filter-label"><input type="checkbox" v-model="pendingUnratedOnly" /> Unrated levels only</label>
|
||||
<div style="margin-top:4px;">
|
||||
<label style="font-size:13px; color:#888; display:block; margin-bottom:4px;">Min enjoyment: {{ pendingMinEnjoyment }}</label>
|
||||
<input type="range" min="0" max="100" v-model.number="pendingMinEnjoyment" style="width:100%;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="applyFilters" class="cl-filter-apply">Apply</button>
|
||||
</div>
|
||||
<!-- Click-outside backdrop to close menu -->
|
||||
<div v-if="menuOpen" class="cl-menu-backdrop" @click="menuOpen = false"></div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Section label -->
|
||||
<div class="cl-section-label">
|
||||
<span>{{ filteredList.filter(([,r]) => r != null && r <= 150).length }} levels</span>
|
||||
<span v-if="filteredList.some(([,r]) => r > 150)"> · including legacy</span>
|
||||
</div>
|
||||
|
||||
<!-- Card list -->
|
||||
<div class="cl-list">
|
||||
|
||||
<!-- Main list separator -->
|
||||
<div class="cl-separator" v-if="filteredList.some(([,r]) => r != null && r <= 150)">
|
||||
<span>The List</span>
|
||||
</div>
|
||||
|
||||
<template v-for="([err, rank, level]) in filteredList" :key="level?.id">
|
||||
<button
|
||||
v-if="rank != null && rank <= 150 && level"
|
||||
class="cl-card"
|
||||
@click="openCard(level)"
|
||||
>
|
||||
<!-- Blurred level thumbnail background -->
|
||||
<div
|
||||
class="cl-card-bg"
|
||||
:style="{ backgroundImage: \`url('/assets/levels/\${level.name}.png')\` }"
|
||||
></div>
|
||||
<div class="cl-card-tint"></div>
|
||||
|
||||
<!-- YouTube thumbnail -->
|
||||
<div class="cl-card-yt" v-if="ytThumb(level.verification)">
|
||||
<img
|
||||
:src="ytThumb(level.verification)"
|
||||
:alt="level.name + ' verification'"
|
||||
class="cl-card-yt-img"
|
||||
/>
|
||||
<div class="cl-card-yt-play">▶</div>
|
||||
</div>
|
||||
<div class="cl-card-yt cl-card-yt-placeholder" v-else></div>
|
||||
|
||||
<!-- Text info -->
|
||||
<div class="cl-card-info">
|
||||
<div class="cl-card-rank">#{{ rank }}</div>
|
||||
<div class="cl-card-name" v-html="highlight(level.name)"></div>
|
||||
<div class="cl-card-meta">
|
||||
<span class="cl-card-pts">{{ score(rank, 100, level.percentToQualify) }} pts</span>
|
||||
<span class="cl-card-dot">·</span>
|
||||
<span class="cl-card-author" v-html="highlight(level.author || '')"></span>
|
||||
<span class="cl-card-dot">·</span>
|
||||
<span class="cl-card-verifier">verified by <span v-html="highlight(level.verifier || '')"></span></span>
|
||||
</div>
|
||||
<div class="cl-card-tags">
|
||||
<span v-if="aredlRanks[level.id]" class="cl-card-tag">AREDL #{{ aredlRanks[level.id] }}</span>
|
||||
<span v-for="pack in (level.packs || [])" :key="pack.name" class="cl-card-tag" :style="{background: pack.colour, color: getFontColour(pack.colour)}">{{ pack.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cl-card-arrow">›</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Legacy separator -->
|
||||
<div class="cl-separator" v-if="filteredList.some(([,r]) => r != null && r > 150)">
|
||||
<span>Legacy List</span>
|
||||
</div>
|
||||
|
||||
<template v-for="([err, rank, level]) in filteredList" :key="'leg-' + level?.id">
|
||||
<button
|
||||
v-if="rank != null && rank > 150 && level"
|
||||
class="cl-card cl-card-legacy"
|
||||
@click="openCard(level)"
|
||||
>
|
||||
<div
|
||||
class="cl-card-bg"
|
||||
:style="{ backgroundImage: \`url('/assets/levels/\${level.name}.png')\` }"
|
||||
></div>
|
||||
<div class="cl-card-tint"></div>
|
||||
|
||||
<div class="cl-card-yt" v-if="ytThumb(level.verification)">
|
||||
<img
|
||||
:src="ytThumb(level.verification)"
|
||||
:alt="level.name + ' verification'"
|
||||
class="cl-card-yt-img"
|
||||
/>
|
||||
<div class="cl-card-yt-play">▶</div>
|
||||
</div>
|
||||
<div class="cl-card-yt cl-card-yt-placeholder" v-else></div>
|
||||
|
||||
<div class="cl-card-info">
|
||||
<div class="cl-card-rank">#{{ rank }} <span class="cl-legacy-badge">Legacy</span></div>
|
||||
<div class="cl-card-name" v-html="highlight(level.name)"></div>
|
||||
<div class="cl-card-meta">
|
||||
<span class="cl-card-pts">{{ score(rank, 100, level.percentToQualify) }} pts</span>
|
||||
<span class="cl-card-dot">·</span>
|
||||
<span class="cl-card-author" v-html="highlight(level.author || '')"></span>
|
||||
<span class="cl-card-dot">·</span>
|
||||
<span class="cl-card-verifier">verified by <span v-html="highlight(level.verifier || '')"></span></span>
|
||||
</div>
|
||||
<div class="cl-card-tags">
|
||||
<span v-if="aredlRanks[level.id]" class="cl-card-tag">AREDL #{{ aredlRanks[level.id] }}</span>
|
||||
<span v-for="pack in (level.packs || [])" :key="pack.name" class="cl-card-tag" :style="{background: pack.colour, color: getFontColour(pack.colour)}">{{ pack.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cl-card-arrow">›</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
`,
|
||||
|
||||
data: () => ({
|
||||
list: [],
|
||||
editors: [],
|
||||
loading: true,
|
||||
selected: 0,
|
||||
errors: [],
|
||||
roleIconMap,
|
||||
store,
|
||||
toggledShowcase: false,
|
||||
showUnverified: false,
|
||||
aredlRanks: {},
|
||||
searchQuery: '',
|
||||
menuOpen: false,
|
||||
pendingSortBy: 'rank',
|
||||
pendingRatedOnly: false,
|
||||
pendingUnratedOnly: false,
|
||||
pendingMinEnjoyment: 0,
|
||||
sortBy: 'rank',
|
||||
filterRatedOnly: false,
|
||||
filterUnratedOnly: false,
|
||||
filterMinEnjoyment: 0,
|
||||
// Card layout state
|
||||
cardSelected: null,
|
||||
}),
|
||||
|
||||
computed: {
|
||||
level() {
|
||||
return this.list && this.list[this.selected] && this.list[this.selected][2];
|
||||
},
|
||||
video() {
|
||||
if (!this.level.showcase) {
|
||||
return embed(this.level.verification);
|
||||
}
|
||||
return embed(
|
||||
this.toggledShowcase ? this.level.showcase : this.level.verification,
|
||||
);
|
||||
},
|
||||
dropdownStyle() {
|
||||
// Position dropdown anchored to the filter button via fixed coords
|
||||
// so it escapes every overflow/stacking context in the page
|
||||
if (!this.menuOpen || !this.$refs.filterBtn) return { display: 'none' };
|
||||
const r = this.$refs.filterBtn.getBoundingClientRect();
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: (r.bottom + 6) + 'px',
|
||||
right: (window.innerWidth - r.right) + 'px',
|
||||
zIndex: 99999,
|
||||
};
|
||||
},
|
||||
// Video for the card panel
|
||||
cardVideo() {
|
||||
if (!this.cardSelected) return '';
|
||||
if (!this.cardSelected.showcase) return embed(this.cardSelected.verification);
|
||||
return embed(this.toggledShowcase ? this.cardSelected.showcase : this.cardSelected.verification);
|
||||
},
|
||||
filteredList() {
|
||||
const q = (this.searchQuery || '').toLowerCase().trim();
|
||||
let filtered = this.list.filter(([err, rank, level]) => {
|
||||
if (!level || !level.name) return false;
|
||||
if (q && !(
|
||||
level.name.toLowerCase().includes(q) ||
|
||||
(level.author || '').toLowerCase().includes(q) ||
|
||||
(level.creators || []).some(c => c.toLowerCase().includes(q))
|
||||
)) return false;
|
||||
const hasAredlRank = !!this.aredlRanks[level.id];
|
||||
if (this.filterRatedOnly && !hasAredlRank) return false;
|
||||
if (this.filterUnratedOnly && hasAredlRank) return false;
|
||||
if ((level.enjoyment ?? 0) < this.filterMinEnjoyment) return false;
|
||||
return true;
|
||||
});
|
||||
return filtered.sort(([, , a], [, , b]) => {
|
||||
if (!a || !b) return 0;
|
||||
switch (this.sortBy) {
|
||||
case 'rank': return (a.rank ?? Infinity) - (b.rank ?? Infinity);
|
||||
case 'enjoyment': return (b.enjoyment ?? 0) - (a.enjoyment ?? 0);
|
||||
case 'name': return a.name.localeCompare(b.name);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
store.list = this;
|
||||
await resetList();
|
||||
|
||||
const levelName = getLevelFromURL();
|
||||
if (levelName) {
|
||||
const index = this.list.findIndex(([, , l]) => l?.name === levelName);
|
||||
if (index !== -1) {
|
||||
this.selected = index;
|
||||
this.selectLevel(this.list[index][2]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, , level] of this.list) {
|
||||
if (level?.id) this.getAredlRank(level.id);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
embed,
|
||||
score,
|
||||
getFontColour,
|
||||
getLevelThumbnail,
|
||||
ytThumb,
|
||||
selectLevel(level) {
|
||||
const index = this.list.findIndex(([, , l]) => l?.id === level?.id);
|
||||
if (index !== -1) {
|
||||
this.selected = index;
|
||||
document.querySelector('.dark.root').style.background =
|
||||
`url("/assets/levels/${level.name}.png") #000000e3`;
|
||||
this.scrollToSelected();
|
||||
}
|
||||
},
|
||||
toggleMenu() {
|
||||
this.menuOpen = !this.menuOpen;
|
||||
// Re-compute position on next tick after button is in DOM
|
||||
this.$nextTick(() => { /* dropdownStyle computed auto-updates */ });
|
||||
},
|
||||
openCard(level) {
|
||||
this.toggledShowcase = false;
|
||||
this.cardSelected = level;
|
||||
document.querySelector('.dark.root').style.background =
|
||||
`url("/assets/levels/${level.name}.png") #000000e3`;
|
||||
},
|
||||
async getAredlRank(id) {
|
||||
if (this.aredlRanks[id]) return;
|
||||
let response = await fetch(`https://api.aredl.net/v2/api/aredl/levels/${id}`);
|
||||
if(response.status == 404)
|
||||
{
|
||||
response = await fetch(`https://api.aredl.net/v2/api/arepl/levels/${id}`);
|
||||
}
|
||||
const levelInfo = await response.json();
|
||||
this.aredlRanks[id] = levelInfo.position;
|
||||
},
|
||||
highlight(text) {
|
||||
const q = (this.searchQuery || '').trim();
|
||||
if (!q) return text;
|
||||
const safe = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${safe})`, 'ig');
|
||||
return text.replace(regex, `<span class="search-highlight">$1</span>`);
|
||||
},
|
||||
previewBackground(level) {
|
||||
if (!level) return;
|
||||
document.querySelector('.dark.root').style.background =
|
||||
`url("/assets/levels/${level.name}.png") #000000e3`;
|
||||
},
|
||||
applyFilters() {
|
||||
this.sortBy = this.pendingSortBy;
|
||||
this.filterRatedOnly = this.pendingRatedOnly;
|
||||
this.filterUnratedOnly = this.pendingUnratedOnly;
|
||||
this.filterMinEnjoyment = this.pendingMinEnjoyment;
|
||||
this.menuOpen = false;
|
||||
this.scrollToSelected();
|
||||
},
|
||||
scrollToSelected() {
|
||||
this.$nextTick(() => {
|
||||
const activeBtn = document.querySelector('.level.active button');
|
||||
if (activeBtn) {
|
||||
activeBtn.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
filteredList(newList) {
|
||||
if (!newList.length) return;
|
||||
// reserved for future use
|
||||
},
|
||||
// Reset showcase toggle when switching cards
|
||||
cardSelected() {
|
||||
this.toggledShowcase = false;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export async function resetList() {
|
||||
console.log("resetting");
|
||||
store.list.loading = true;
|
||||
store.list.aredlRanks = {};
|
||||
store.list.list = await fetchList();
|
||||
store.list.editors = await fetchEditors();
|
||||
|
||||
if (!store.list.list) {
|
||||
store.list.errors = [
|
||||
"Failed to load list. Retry in a few minutes or notify list staff.",
|
||||
];
|
||||
} else {
|
||||
store.list.errors.push(
|
||||
...store.list.list
|
||||
.filter(([err, _, __]) => err)
|
||||
.map(([err, _, __]) => `Failed to load level. (${err}.json)`)
|
||||
);
|
||||
if (!store.list.editors) {
|
||||
store.list.errors.push("Failed to load list editors.");
|
||||
}
|
||||
}
|
||||
|
||||
store.list.showUnverified = false;
|
||||
|
||||
for (var i = 0; i < store.list.list.length; i++) {
|
||||
if (store.list.list[i][1] != null) {
|
||||
store.list.selected = i;
|
||||
store.list.selectLevel(store.list.list[i][2]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
store.list.loading = false;
|
||||
|
||||
for (const [, , level] of store.list.list) {
|
||||
if (level?.id) store.list.getAredlRank(level.id);
|
||||
}
|
||||
}
|
||||
|
||||
function getLevelFromURL() {
|
||||
const hash = window.location.hash.split('?')[1] || '';
|
||||
const params = new URLSearchParams(hash);
|
||||
return params.get('level');
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
459
js/pages/OLDLISTJSONLYAREDLLAYOUT.js
Normal file
459
js/pages/OLDLISTJSONLYAREDLLAYOUT.js
Normal file
@@ -0,0 +1,459 @@
|
||||
import { store } from '../main.js';
|
||||
import { embed, getFontColour, getLevelThumbnail } from '../util.js';
|
||||
import { score } from '../score.js';
|
||||
import { fetchEditors, fetchList } from '../content.js';
|
||||
|
||||
import Spinner from '../components/Spinner.js';
|
||||
import LevelAuthors from '../components/List/LevelAuthors.js';
|
||||
|
||||
const roleIconMap = {
|
||||
owner: 'crown',
|
||||
member: 'cube',
|
||||
admin: 'user-gear',
|
||||
kreo: 'kreo',
|
||||
seniormod: 'user-shield',
|
||||
mod: 'user-lock',
|
||||
trial: 'user-check',
|
||||
dev: 'code'
|
||||
};
|
||||
|
||||
export default {
|
||||
components: { Spinner, LevelAuthors },
|
||||
template: `
|
||||
<main v-if="loading" class="surface">
|
||||
<Spinner></Spinner>
|
||||
</main>
|
||||
<main v-else class="page-list">
|
||||
<div class="list-container surface">
|
||||
<!-- removing this thing
|
||||
<input type="checkbox" id="sul-checkbox" name="sul-check" value="showUnverified" @click="showUnverified = !showUnverified">
|
||||
<label for="sul-checkbox" class="sul-label">Show Upcoming/Unverified Levels</label><br>
|
||||
|
||||
<br v-if="!showUnverified">-->
|
||||
<div style="display:flex; gap:10px; margin-bottom:10px; align-items:center;">
|
||||
<input
|
||||
id="levelSearch"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
placeholder="Search levels..."
|
||||
aria-label="Search levels"
|
||||
autocomplete="off"
|
||||
style="flex:1; padding:10px 12px; border-radius:8px; border:none; background:#2a2a2a; color:#fff;"
|
||||
/>
|
||||
|
||||
<!--<select v-model="sortBy" style="padding:8px 12px; border-radius:6px; background:#2a2a2a; color:#fff; border:none;" selected="rank">
|
||||
<option value="rank">Rank</option>
|
||||
<option value="enjoyment">Enjoyment</option>
|
||||
<option value="name">Name</option>
|
||||
</select>-->
|
||||
|
||||
<!-- sorting options -->
|
||||
<div style="position:relative;">
|
||||
<button @click="menuOpen = !menuOpen" style="padding:8px 14px; border-radius:8px; display:flex; align-items:center; gap:6px; background:#2a2a2a; color:#fff; border:none; cursor:pointer;">
|
||||
<span style="font-size:14px;">Sort & Filter</span>
|
||||
<span style="font-size:10px;">{{ menuOpen ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="menuOpen" style="position:absolute; right:0; top:calc(100% + 6px); width:280px; background:#1e1e1e; border:1px solid #444; border-radius:10px; padding:1rem; z-index:100;">
|
||||
|
||||
<p style="font-size:12px; color:#888; margin:0 0 8px; text-transform:uppercase;">Sort by</p>
|
||||
<div style="display:flex; flex-direction:column; gap:6px; margin-bottom:1rem;">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:14px; color:#fff; cursor:pointer;">
|
||||
<input type="radio" v-model="pendingSortBy" value="rank" /> Rank
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:14px; color:#fff; cursor:pointer;">
|
||||
<input type="radio" v-model="pendingSortBy" value="enjoyment" /> Enjoyment
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:14px; color:#fff; cursor:pointer;">
|
||||
<input type="radio" v-model="pendingSortBy" value="name" /> Name (A–Z)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="border-top:1px solid #333; padding-top:1rem; margin-bottom:1rem;">
|
||||
<p style="font-size:12px; color:#888; margin:0 0 8px; text-transform:uppercase;">Filter</p>
|
||||
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:14px; color:#fff; cursor:pointer;">
|
||||
<input type="checkbox" v-model="pendingRatedOnly" /> Rated levels only
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:14px; color:#fff; cursor:pointer;">
|
||||
<input type="checkbox" v-model="pendingUnratedOnly" /> Unrated levels only
|
||||
</label>
|
||||
<div style="margin-top:4px;">
|
||||
<label style="font-size:13px; color:#888; display:block; margin-bottom:4px;">Min enjoyment: {{ pendingMinEnjoyment }}</label>
|
||||
<input type="range" min="0" max="100" v-model.number="pendingMinEnjoyment" style="width:100%;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="applyFilters" style="width:100%; padding:9px; border-radius:8px; background:#3a6ee8; color:#fff; border:none; font-size:14px; font-weight:500; cursor:pointer;">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="list-separator"><i>The List</i></h2>
|
||||
<table class="list" v-if="list">
|
||||
<tr v-for="([err, rank, level], i) in filteredList">
|
||||
<td class="rank">
|
||||
<p v-if="rank != null && rank <= 150" class="type-label-lg">#{{ rank }}</p>
|
||||
<p v-else-if="rank == null && showUnverified" class="type-label-lg">—</p>
|
||||
</td>
|
||||
<td class="rank-image">
|
||||
<img v-if="rank == 1" class="rank-trophy" src="assets/Top1Trophy.png" />
|
||||
<img v-if="rank == 2" class="rank-trophy" src="assets/Top2Trophy.png" />
|
||||
<img v-if="rank == 3" class="rank-trophy" src="assets/Top3Trophy.png" />
|
||||
<img v-if="rank >= 4" class="rank-trophy" src="assets/NoTrophy.png" />
|
||||
</td>
|
||||
<td class="level" :class="{ 'active': level?.id === this.level?.id, 'error': !level }">
|
||||
<button
|
||||
@click="selectLevel(level)"
|
||||
@mouseenter="previewBackground(level)"
|
||||
@mouseleave="previewBackground(this.level)"
|
||||
v-if="rank != null && rank <= 150"
|
||||
:style="getLevelThumbnail(level.name)"
|
||||
>
|
||||
<span class="type-label-lg" v-html="highlight(level?.name || '')"></span>
|
||||
<span class="type-label-sm" v-html="highlight(level?.author || '')"></span>
|
||||
<span v-if="aredlRanks[level.id]" class="type-label-sm">Aredl #{{ aredlRanks[level.id] ?? "Loading..." }}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h2 class="list-separator" v-if="list[150] != null"><i>Legacy List</i></h2>
|
||||
<table class="list" v-if="list">
|
||||
<tr v-for="([err, rank, level], i) in filteredList">
|
||||
<td class="rank">
|
||||
<p v-if="rank != null && rank > 150" class="type-label-lg">#{{ rank }}</p>
|
||||
</td>
|
||||
<td class="rank-image">
|
||||
<img v-if="rank >= 151" class="rank-trophy" src="assets/NoTrophy.png" />
|
||||
</td>
|
||||
<td class="level" :class="{ 'active': level?.id === this.level?.id, 'error': !level }">
|
||||
<button
|
||||
@click="selectLevel(level)"
|
||||
@mouseenter="previewBackground(level)"
|
||||
v-if="rank != null && rank > 150"
|
||||
:style="getLevelThumbnail(level.name)"
|
||||
>
|
||||
<span class="type-label-lg" v-html="highlight(level?.name || '')"></span>
|
||||
<span class="type-label-sm" v-html="highlight(level?.verifier || '')"></span>
|
||||
<span v-if="aredlRanks[level.id]" class="type-label-sm">Aredl #{{ aredlRanks[level.id] ?? "Loading..." }}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="level-container surface">
|
||||
<div class="level" v-if="level">
|
||||
<h1>{{ level.name }}</h1>
|
||||
|
||||
<p><i>"{{level.gdleveldescription || "(No description provided)"}}"</i></p>
|
||||
|
||||
<LevelAuthors :author="level.author" :creators="level.creators" :verifier="level.verifier"></LevelAuthors>
|
||||
|
||||
<p>{{level.description || "No Description Provided."}}</p>
|
||||
|
||||
<div class="packs" v-if="level.packs.length > 0">
|
||||
<div v-for="pack in level.packs" class="tag" :style="{background:pack.colour}">
|
||||
<p :style="{color:getFontColour(pack.colour)}">{{pack.name}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="level.showcase" class="tabs">
|
||||
<button class="tab type-label-lg" :class="{selected: !toggledShowcase}" @click="toggledShowcase = false">
|
||||
<span class="type-label-lg">Verification</span>
|
||||
</button>
|
||||
<button class="tab" :class="{selected: toggledShowcase}" @click="toggledShowcase = true">
|
||||
<span class="type-label-lg">Showcase</span>
|
||||
</button>
|
||||
</div>
|
||||
<iframe class="video" id="videoframe" :src="video" frameborder="0"></iframe>
|
||||
<ul class="stats">
|
||||
<li>
|
||||
<div class="type-title-sm">Points when completed</div>
|
||||
<p>{{ score(level.rank, 100, level.percentToQualify) }}</p>
|
||||
</li>
|
||||
<li>
|
||||
<div class="type-title-sm">ID</div>
|
||||
<p>{{ level.id }}</p>
|
||||
</li>
|
||||
<li>
|
||||
<div class="type-title-sm">Enjoyment</div>
|
||||
<p>{{ level.enjoyment }}/100</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else class="level" style="height: 100%; justify-content: center; align-items: center;">
|
||||
<p>(ノಠ益ಠ)ノ彡┻━┻</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-container surface">
|
||||
<div class="meta">
|
||||
<div class="errors" v-show="errors.length > 0">
|
||||
<p class="error" v-for="error of errors">{{ error }}</p>
|
||||
</div>
|
||||
<h2>Records</h2>
|
||||
<p v-if="level.rank == null"><strong>{{ level.percentToQualify }}%</strong> or better to qualify</p>
|
||||
<p v-else-if="level.rank <= 75"><strong>{{ level.percentToQualify }}%</strong> or better to qualify</p>
|
||||
<p v-else><strong>100%</strong> to qualify</p>
|
||||
<table class="records">
|
||||
<tr v-for="record in level.records" class="record">
|
||||
<td class="percent">
|
||||
<p>{{ record.percent }}%</p>
|
||||
</td>
|
||||
<td class="userIcon">
|
||||
<img class="ico" :src="\`/assets/icons/\${record.user}.png\`" :alt="record.user">
|
||||
</td>
|
||||
<td class="user">
|
||||
<a :href="record.link" target="_blank" class="type-label-lg">{{ record.user }}</a>
|
||||
</td>
|
||||
<td class="mobile">
|
||||
<img v-if="record.mobile" :src="\`/assets/phone-landscape\${store?.dark ? '-dark' : ''}.svg\`" alt="Mobile">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!--<template v-if="editors">
|
||||
<h2>John Evil Members</h2>
|
||||
<ol class="editors">
|
||||
<li v-for="editor in editors">
|
||||
<img :src="\`/assets/\${roleIconMap[editor.role]}\${(store.dark || store.shitty) ? '-dark' : ''}.svg\`" :alt="editor.role">
|
||||
<a v-if="editor.link" class="type-label-lg link" target="_blank" :href="editor.link">{{ editor.name }}</a>
|
||||
<p v-else>{{ editor.name }}</p>
|
||||
<img :src="\`/assets/icons/\${editor.name}.png\`" :alt="editor.name">
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
<h2> List Rules </h2>
|
||||
<p><i>read this now</i></p>
|
||||
<h3> Demonlist Rules </h3>
|
||||
<p>Levels must be verified on NO LOWER than 60 fps, and you can't use physics bypass. CBF is allowed.</p>
|
||||
<p>Levels must be possible on 60 fps.</p>
|
||||
<p>Audible clicks are <b><i>required.</i></b> It does not matter if you have a CPS counter.</p>
|
||||
<p>Your previous attempt/entry of the level, full completion, endscreen, and Cheat Indicator must be shown.</p>
|
||||
<p>No secret ways or bug routes allowed.</p>
|
||||
<p>LDM's are allowed as long as they don't make the level any easier to play or see.</p>
|
||||
<p>Bug fixes are allowed as long as they don't nerf the level.</p>
|
||||
<h3> Challenge List Rules </h3>
|
||||
<p>Levels must be verified on NO LOWER than 60 FPS, and NO HIGHER than 360 FPS. Your FPS must be shown.</p>
|
||||
<p>Challenges that force drastic changes depending on the player's FPS; including different routes; are not eligible for the list!</p>
|
||||
<p>Recordings are REQUIRED for all challenges! PC recordings must include audible clicks and the last death attempt before completion!</p>
|
||||
<p>Challenges that fall out of the top 150 will be moved into the Legacy List.</p>
|
||||
<p>Challenges that require more than 16 CPS spam are not eligible for the list!</p>
|
||||
<p>Levels that are 30 seconds or longer are not eligible for the list! Challenges must be 29 seconds or shorter.</p>
|
||||
<h3> Info </h3>
|
||||
<p>Levels that fall out of the top 150 will be moves into the Legacy List.</p>
|
||||
<p>Levels that are deemed not list-worthy will not be placed at all.</p>
|
||||
<p>Banned members are ineligible for submitting future records.</p>
|
||||
<p>Packs do not give bonus points; they are simply an extra incentive.</p>-->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
`,
|
||||
data: () => ({
|
||||
list: [],
|
||||
editors: [],
|
||||
loading: true,
|
||||
selected: 0,
|
||||
errors: [],
|
||||
roleIconMap,
|
||||
store,
|
||||
toggledShowcase: false,
|
||||
showUnverified: false,
|
||||
aredlRanks: {},
|
||||
searchQuery: '',
|
||||
|
||||
// ... your existing properties ...
|
||||
menuOpen: false,
|
||||
// "pending" = what's in the menu before hitting Apply
|
||||
pendingSortBy: 'rank',
|
||||
pendingRatedOnly: false,
|
||||
pendingUnratedOnly: false,
|
||||
pendingMinEnjoyment: 0,
|
||||
// "active" = what's actually applied
|
||||
sortBy: 'rank',
|
||||
filterRatedOnly: false,
|
||||
filterUnratedOnly: false,
|
||||
filterMinEnjoyment: 0,
|
||||
}),
|
||||
computed: {
|
||||
level() {
|
||||
return this.list && this.list[this.selected] && this.list[this.selected][2];
|
||||
},
|
||||
video() {
|
||||
if (!this.level.showcase) {
|
||||
return embed(this.level.verification);
|
||||
}
|
||||
|
||||
return embed(
|
||||
this.toggledShowcase ? this.level.showcase : this.level.verification,
|
||||
);
|
||||
},
|
||||
filteredList() {
|
||||
const q = (this.searchQuery || '').toLowerCase().trim();
|
||||
|
||||
let filtered = this.list.filter(([err, rank, level]) => {
|
||||
if (!level || !level.name) return false;
|
||||
|
||||
// text search
|
||||
if (q && !(
|
||||
level.name.toLowerCase().includes(q) ||
|
||||
(level.author || '').toLowerCase().includes(q) ||
|
||||
(level.creators || []).some(c => c.toLowerCase().includes(q))
|
||||
)) return false;
|
||||
|
||||
const hasAredlRank = !!this.aredlRanks[level.id];
|
||||
// rank filters (these are mutually exclusive in practice)
|
||||
if (this.filterRatedOnly && !hasAredlRank) return false;
|
||||
if (this.filterUnratedOnly && hasAredlRank) return false;
|
||||
|
||||
// enjoyment filter
|
||||
if ((level.enjoyment ?? 0) < this.filterMinEnjoyment) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return filtered.sort(([, , a], [, , b]) => {
|
||||
if (!a || !b) return 0;
|
||||
switch (this.sortBy) {
|
||||
case 'rank': return (a.rank ?? Infinity) - (b.rank ?? Infinity);
|
||||
case 'enjoyment': return (b.enjoyment ?? 0) - (a.enjoyment ?? 0);
|
||||
case 'name': return a.name.localeCompare(b.name);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
store.list = this;
|
||||
await resetList();
|
||||
|
||||
// Grab the level from URL
|
||||
const levelName = getLevelFromURL();
|
||||
if (levelName) {
|
||||
// find index in the list
|
||||
const index = this.list.findIndex(([, , l]) => l?.name === levelName);
|
||||
if (index !== -1) {
|
||||
this.selected = index;
|
||||
this.selectLevel(this.list[index][2]); // select it
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, , level] of this.list) {
|
||||
this.getAredlRank(level.id);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
embed,
|
||||
score,
|
||||
getFontColour,
|
||||
getLevelThumbnail,
|
||||
selectLevel(level) {
|
||||
const index = this.list.findIndex(([, , l]) => l?.id === level?.id);
|
||||
|
||||
if (index !== -1) {
|
||||
this.selected = index;
|
||||
document.querySelector('.dark.root').style.background =
|
||||
`url("/assets/levels/${level.name}.png") #000000e3`;
|
||||
this.scrollToSelected();
|
||||
}
|
||||
},
|
||||
async getAredlRank(id) {
|
||||
if (this.aredlRanks[id]) return;
|
||||
|
||||
const response = await fetch(`https://api.aredl.net/v2/api/aredl/levels/${id}`);
|
||||
const levelInfo = await response.json();
|
||||
|
||||
this.aredlRanks[id] = levelInfo.position;
|
||||
},
|
||||
highlight(text) {
|
||||
const q = (this.searchQuery || '').trim();
|
||||
|
||||
if (!q) return text;
|
||||
|
||||
const safe = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${safe})`, 'ig');
|
||||
|
||||
return text.replace(regex, `<span class="search-highlight">$1</span>`);
|
||||
},
|
||||
previewBackground(level) {
|
||||
document.querySelector('.dark.root').style.background =
|
||||
`url("/assets/levels/${level.name}.png") #000000e3`;
|
||||
},
|
||||
applyFilters() {
|
||||
this.sortBy = this.pendingSortBy;
|
||||
this.filterRatedOnly = this.pendingRatedOnly;
|
||||
this.filterUnratedOnly = this.pendingUnratedOnly;
|
||||
this.filterMinEnjoyment = this.pendingMinEnjoyment;
|
||||
this.menuOpen = false;
|
||||
this.scrollToSelected();
|
||||
},
|
||||
scrollToSelected() {
|
||||
this.$nextTick(() => {
|
||||
const activeBtn = document.querySelector('.level.active button');
|
||||
if (activeBtn) {
|
||||
activeBtn.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
filteredList(newList) {
|
||||
if (!newList.length) return;
|
||||
|
||||
const level = newList[0][2];
|
||||
const index = this.list.findIndex(([, , l]) => l?.id === level?.id);
|
||||
|
||||
/*if (index !== -1) {
|
||||
this.selectLevel(level);
|
||||
}*/
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function resetList() {
|
||||
console.log("resetting");
|
||||
|
||||
store.list.loading = true;
|
||||
|
||||
// Hide loading spinner
|
||||
store.list.list = await fetchList();
|
||||
store.list.editors = await fetchEditors();
|
||||
|
||||
// Error handling
|
||||
if (!store.list.list) {
|
||||
store.list.errors = [
|
||||
"Failed to load list. Retry in a few minutes or notify list staff.",
|
||||
];
|
||||
} else {
|
||||
store.list.errors.push(
|
||||
...store.list.list
|
||||
.filter(([err, _, __]) => err)
|
||||
.map(([err, _, __]) => {
|
||||
return `Failed to load level. (${err}.json)`;
|
||||
})
|
||||
);
|
||||
if (!store.list.editors) {
|
||||
store.list.errors.push("Failed to load list editors.");
|
||||
}
|
||||
}
|
||||
|
||||
store.list.showUnverified = false;
|
||||
|
||||
for(var i = 0; i < store.list.list.length; i++){
|
||||
if(store.list.list[i][1] != null){
|
||||
store.list.selected = i;
|
||||
|
||||
store.list.selectLevel(i, store.list.list[i][2]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
store.list.loading = false;
|
||||
}
|
||||
|
||||
function getLevelFromURL() {
|
||||
// window.location.hash looks like: "#/?level=Bloodbath"
|
||||
const hash = window.location.hash.split('?')[1] || '';
|
||||
const params = new URLSearchParams(hash);
|
||||
return params.get('level'); // returns "Bloodbath" or null
|
||||
}
|
||||
376
js/pages/Roulette.js
Normal file
376
js/pages/Roulette.js
Normal file
@@ -0,0 +1,376 @@
|
||||
import { fetchList } from "../content.js";
|
||||
import { getThumbnailFromId, getYoutubeIdFromUrl, shuffle } from "../util.js";
|
||||
|
||||
import Spinner from "../components/Spinner.js";
|
||||
import Btn from "../components/Btn.js";
|
||||
|
||||
export default {
|
||||
components: { Spinner, Btn },
|
||||
template: `
|
||||
<main v-if="loading">
|
||||
<Spinner></Spinner>
|
||||
</main>
|
||||
<main v-else class="page-roulette">
|
||||
|
||||
<!-- ══════════════════════════════
|
||||
SIDEBAR
|
||||
══════════════════════════════ -->
|
||||
<div class="sidebar surface">
|
||||
<div class="sidebar-header">
|
||||
<h2 class="sidebar-title">Demon Roulette</h2>
|
||||
<p class="sidebar-credit">
|
||||
Based on the <a href="https://matcool.github.io/extreme-demon-roulette/" target="_blank">Extreme Demon Roulette</a> by matcool.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress summary (only when active) -->
|
||||
<div class="sidebar-progress" v-if="levels.length > 0">
|
||||
<div class="sp-row">
|
||||
<span class="sp-label">Progress</span>
|
||||
<span class="sp-val">{{ progression.length }} / {{ levels.length }}</span>
|
||||
</div>
|
||||
<div class="sp-track">
|
||||
<div class="sp-fill" :style="{ width: progressPct + '%' }"></div>
|
||||
</div>
|
||||
<div class="sp-row">
|
||||
<span class="sp-label">Highest %</span>
|
||||
<span class="sp-val sp-pct">{{ currentPercentage }}%</span>
|
||||
</div>
|
||||
<div class="sp-row" v-if="streak > 1">
|
||||
<span class="sp-label">Streak 🔥</span>
|
||||
<span class="sp-val sp-streak">{{ streak }} levels</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<form class="options">
|
||||
<p class="options-heading">Include</p>
|
||||
<div class="check">
|
||||
<input type="checkbox" id="main" v-model="useMainList">
|
||||
<label for="main">Main List <span class="check-sub">#1–75</span></label>
|
||||
</div>
|
||||
<div class="check">
|
||||
<input type="checkbox" id="extended" v-model="useExtendedList">
|
||||
<label for="extended">Extended List <span class="check-sub">#76–150</span></label>
|
||||
</div>
|
||||
<div class="check">
|
||||
<input type="checkbox" id="legacy" v-model="useLegacyList">
|
||||
<label for="legacy">Legacy List <span class="check-sub">#151+</span></label>
|
||||
</div>
|
||||
<Btn @click.native.prevent="onStart" class="btn-start">
|
||||
{{ levels.length === 0 ? 'Start Roulette' : 'Restart' }}
|
||||
</Btn>
|
||||
</form>
|
||||
|
||||
<!-- Save/load -->
|
||||
<div class="save">
|
||||
<p class="options-heading">Save / Load</p>
|
||||
<p class="sidebar-credit">Progress saves automatically.</p>
|
||||
<div class="btns">
|
||||
<Btn @click.native.prevent="onImport">Import</Btn>
|
||||
<Btn :disabled="!isActive" @click.native.prevent="onExport">Export</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════
|
||||
LEVEL FEED
|
||||
══════════════════════════════ -->
|
||||
<section class="levels-container surface">
|
||||
<div class="levels">
|
||||
<template v-if="levels.length > 0">
|
||||
|
||||
<!-- Empty state before starting -->
|
||||
<div class="roulette-empty" v-if="progression.length === 0 && !hasCompleted">
|
||||
<p class="roulette-empty-icon">🎲</p>
|
||||
<p class="roulette-empty-text">Your first level is waiting…</p>
|
||||
</div>
|
||||
|
||||
<!-- Completed levels -->
|
||||
<div
|
||||
class="level level-done"
|
||||
v-for="(level, i) in levels.slice(0, progression.length)"
|
||||
:key="'done-' + i"
|
||||
>
|
||||
<div class="level-bg" :style="{ backgroundImage: \`url('/assets/levels/\${level.name}.png')\` }"></div>
|
||||
<div class="level-tint level-tint-done"></div>
|
||||
<a :href="level.video" target="_blank" class="video">
|
||||
<img :src="getThumbnailFromId(getYoutubeIdFromUrl(level.video))" alt="">
|
||||
<div class="video-play">▶</div>
|
||||
</a>
|
||||
<div class="meta">
|
||||
<span class="meta-rank">#{{ level.rank }}</span>
|
||||
<h2 class="meta-name">{{ level.name }}</h2>
|
||||
<span class="meta-pct meta-pct-done">{{ progression[i] }}% ✓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current level -->
|
||||
<div class="level level-current" v-if="!hasCompleted && currentLevel">
|
||||
<div class="level-bg" :style="{ backgroundImage: \`url('/assets/levels/\${currentLevel.name}.png')\` }"></div>
|
||||
<div class="level-tint level-tint-current"></div>
|
||||
<a :href="currentLevel.video" target="_blank" class="video">
|
||||
<img :src="getThumbnailFromId(getYoutubeIdFromUrl(currentLevel.video))" alt="">
|
||||
<div class="video-play">▶</div>
|
||||
</a>
|
||||
<div class="meta">
|
||||
<span class="meta-rank">#{{ currentLevel.rank }}</span>
|
||||
<h2 class="meta-name">{{ currentLevel.name }}</h2>
|
||||
<span class="meta-id">{{ currentLevel.id }}</span>
|
||||
<span class="meta-badge">Current</span>
|
||||
</div>
|
||||
<form class="actions" v-if="!givenUp">
|
||||
<input
|
||||
type="number"
|
||||
v-model="percentage"
|
||||
:placeholder="placeholder"
|
||||
:min="currentPercentage + 1"
|
||||
max="100"
|
||||
class="pct-input"
|
||||
/>
|
||||
<Btn @click.native.prevent="onDone" class="btn-done">Done</Btn>
|
||||
<Btn @click.native.prevent="onGiveUp" class="btn-giveup">Give Up</Btn>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div v-if="givenUp || hasCompleted" class="results">
|
||||
<div class="results-icon">{{ hasCompleted ? '🏆' : '💀' }}</div>
|
||||
<h1 class="results-title">{{ hasCompleted ? 'Completed!' : 'Game Over' }}</h1>
|
||||
<div class="results-stats">
|
||||
<div class="rstat">
|
||||
<span class="rstat-val">{{ progression.length }}</span>
|
||||
<span class="rstat-key">Levels</span>
|
||||
</div>
|
||||
<div class="rstat">
|
||||
<span class="rstat-val">{{ currentPercentage }}%</span>
|
||||
<span class="rstat-key">Best %</span>
|
||||
</div>
|
||||
<div class="rstat">
|
||||
<span class="rstat-val">{{ streak }}</span>
|
||||
<span class="rstat-key">Peak Streak</span>
|
||||
</div>
|
||||
</div>
|
||||
<Btn
|
||||
v-if="currentPercentage < 99 && !hasCompleted"
|
||||
@click.native.prevent="showRemaining = true"
|
||||
class="btn-show-remaining"
|
||||
>Show remaining levels</Btn>
|
||||
</div>
|
||||
|
||||
<!-- Remaining levels -->
|
||||
<template v-if="givenUp && showRemaining">
|
||||
<div
|
||||
class="level level-remaining"
|
||||
v-for="(level, i) in levels.slice(progression.length + 1, levels.length - currentPercentage + progression.length)"
|
||||
:key="'rem-' + i"
|
||||
>
|
||||
<div class="level-bg" :style="{ backgroundImage: \`url('/assets/levels/\${level.name}.png')\` }"></div>
|
||||
<div class="level-tint level-tint-remaining"></div>
|
||||
<a :href="level.video" target="_blank" class="video">
|
||||
<img :src="getThumbnailFromId(getYoutubeIdFromUrl(level.video))" alt="">
|
||||
<div class="video-play">▶</div>
|
||||
</a>
|
||||
<div class="meta">
|
||||
<span class="meta-rank">#{{ level.rank }}</span>
|
||||
<h2 class="meta-name">{{ level.name }}</h2>
|
||||
<span class="meta-pct meta-pct-missed">Would need {{ currentPercentage + 2 + i }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Nothing started yet -->
|
||||
<div class="roulette-start-prompt" v-if="levels.length === 0">
|
||||
<p class="roulette-start-icon">🎮</p>
|
||||
<p class="roulette-start-text">Pick your lists and hit Start to begin.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════
|
||||
TOASTS
|
||||
══════════════════════════════ -->
|
||||
<div class="toasts-container">
|
||||
<transition-group name="toast" tag="div" class="toasts">
|
||||
<div v-for="(toast, i) in toasts" :key="i" class="toast">
|
||||
<span class="toast-icon">⚠️</span>
|
||||
<p>{{ toast }}</p>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</main>
|
||||
`,
|
||||
|
||||
data: () => ({
|
||||
loading: false,
|
||||
levels: [],
|
||||
progression: [],
|
||||
percentage: undefined,
|
||||
givenUp: false,
|
||||
showRemaining: false,
|
||||
useMainList: true,
|
||||
useExtendedList: true,
|
||||
useLegacyList: false,
|
||||
toasts: [],
|
||||
fileInput: undefined,
|
||||
peakStreak: 0,
|
||||
}),
|
||||
|
||||
mounted() {
|
||||
this.fileInput = document.createElement("input");
|
||||
this.fileInput.type = "file";
|
||||
this.fileInput.multiple = false;
|
||||
this.fileInput.accept = ".json";
|
||||
this.fileInput.addEventListener("change", this.onImportUpload);
|
||||
|
||||
const roulette = JSON.parse(localStorage.getItem("roulette"));
|
||||
if (!roulette) return;
|
||||
this.levels = roulette.levels;
|
||||
this.progression = roulette.progression;
|
||||
this.peakStreak = roulette.peakStreak || 0;
|
||||
},
|
||||
|
||||
computed: {
|
||||
currentLevel() {
|
||||
return this.levels[this.progression.length];
|
||||
},
|
||||
currentPercentage() {
|
||||
return this.progression[this.progression.length - 1] || 0;
|
||||
},
|
||||
placeholder() {
|
||||
return `At least ${this.currentPercentage + 1}%`;
|
||||
},
|
||||
hasCompleted() {
|
||||
return (
|
||||
this.progression[this.progression.length - 1] >= 100 ||
|
||||
this.progression.length === this.levels.length
|
||||
);
|
||||
},
|
||||
isActive() {
|
||||
return (
|
||||
this.progression.length > 0 &&
|
||||
!this.givenUp &&
|
||||
!this.hasCompleted
|
||||
);
|
||||
},
|
||||
// 0–100 based on highest % reached, not levels completed
|
||||
progressPct() {
|
||||
return this.currentPercentage;
|
||||
},
|
||||
// current consecutive streak of completed levels
|
||||
streak() {
|
||||
if (this.givenUp || this.hasCompleted) return this.peakStreak;
|
||||
return this.progression.length;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
shuffle,
|
||||
getThumbnailFromId,
|
||||
getYoutubeIdFromUrl,
|
||||
|
||||
async onStart() {
|
||||
if (this.isActive) {
|
||||
this.showToast("Give up before starting a new roulette.");
|
||||
return;
|
||||
}
|
||||
if (!this.useMainList && !this.useExtendedList && !this.useLegacyList) return;
|
||||
|
||||
this.loading = true;
|
||||
|
||||
const fullListWithUnverifiedLevels = await fetchList();
|
||||
const fullList = fullListWithUnverifiedLevels.filter(([_, rank]) => rank !== null);
|
||||
|
||||
if (fullList.filter(([err]) => err).length > 0) {
|
||||
this.loading = false;
|
||||
this.showToast("List is currently broken. Wait until it's fixed to start a roulette.");
|
||||
return;
|
||||
}
|
||||
|
||||
const fullListMapped = fullList.map(([_, rank, lvl], i) => ({
|
||||
rank: i + 1,
|
||||
id: lvl.id,
|
||||
name: lvl.name,
|
||||
video: lvl.verification,
|
||||
}));
|
||||
|
||||
const list = [];
|
||||
if (this.useMainList) list.push(...fullListMapped.slice(0, 75));
|
||||
if (this.useExtendedList) list.push(...fullListMapped.slice(75, 150));
|
||||
if (this.useLegacyList) list.push(...fullListMapped.slice(150));
|
||||
|
||||
this.levels = shuffle(list).slice(0, 100);
|
||||
this.showRemaining = false;
|
||||
this.givenUp = false;
|
||||
this.progression = [];
|
||||
this.percentage = undefined;
|
||||
this.peakStreak = 0;
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
save() {
|
||||
localStorage.setItem("roulette", JSON.stringify({
|
||||
levels: this.levels,
|
||||
progression: this.progression,
|
||||
peakStreak: this.peakStreak,
|
||||
}));
|
||||
},
|
||||
|
||||
onDone() {
|
||||
if (!this.percentage) return;
|
||||
if (this.percentage <= this.currentPercentage || this.percentage > 100) {
|
||||
this.showToast("Invalid percentage.");
|
||||
return;
|
||||
}
|
||||
this.progression.push(this.percentage);
|
||||
this.peakStreak = Math.max(this.peakStreak, this.progression.length);
|
||||
this.percentage = undefined;
|
||||
this.save();
|
||||
},
|
||||
|
||||
onGiveUp() {
|
||||
this.givenUp = true;
|
||||
localStorage.removeItem("roulette");
|
||||
},
|
||||
|
||||
onImport() {
|
||||
if (this.isActive && !window.confirm("This will overwrite the currently running roulette. Continue?")) return;
|
||||
this.fileInput.showPicker();
|
||||
},
|
||||
|
||||
async onImportUpload() {
|
||||
if (this.fileInput.files.length === 0) return;
|
||||
const file = this.fileInput.files[0];
|
||||
if (file.type !== "application/json") { this.showToast("Invalid file."); return; }
|
||||
try {
|
||||
const roulette = JSON.parse(await file.text());
|
||||
if (!roulette.levels || !roulette.progression) { this.showToast("Invalid file."); return; }
|
||||
this.levels = roulette.levels;
|
||||
this.progression = roulette.progression;
|
||||
this.peakStreak = roulette.peakStreak || 0;
|
||||
this.save();
|
||||
this.givenUp = false;
|
||||
this.showRemaining = false;
|
||||
this.percentage = undefined;
|
||||
} catch {
|
||||
this.showToast("Invalid file.");
|
||||
}
|
||||
},
|
||||
|
||||
onExport() {
|
||||
const file = new Blob([JSON.stringify({ levels: this.levels, progression: this.progression, peakStreak: this.peakStreak })], { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(file);
|
||||
a.download = "tsl_roulette";
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
},
|
||||
|
||||
showToast(msg) {
|
||||
this.toasts.push(msg);
|
||||
setTimeout(() => this.toasts.shift(), 3000);
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user