Files
TheEvilList/js/pages/OLDLISTJSONLYAREDLLAYOUT.js
2026-04-17 12:29:41 -04:00

459 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (AZ)
</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">&mdash;</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
}