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: `

Sort by

Filter

The List

#{{ rank }}

Legacy List

#{{ rank }}

{{ level.name }}

"{{level.gdleveldescription || "(No description provided)"}}"

{{level.description || "No Description Provided."}}

{{pack.name}}

  • Points when completed

    {{ score(level.rank, 100, level.percentToQualify) }}

  • ID

    {{ level.id }}

  • Enjoyment

    {{ level.enjoyment ?? '—' }}/100

(ノಠ益ಠ)ノ彡┻━┻

{{ error }}

Records

{{ level.percentToQualify }}% or better to qualify

{{ level.percentToQualify }}% or better to qualify

100% to qualify

{{ record.percent }}%

{{ record.user }} Mobile
#{{ cardSelected.rank }}

{{ cardSelected.name }}

"{{ cardSelected.gdleveldescription }}"

(No description provided)

{{ cardSelected.description }}

{{pack.name}}

  • Points when completed

    {{ score(cardSelected.rank, 100, cardSelected.percentToQualify) }}

  • ID

    {{ cardSelected.id }}

  • Enjoyment

    {{ cardSelected.enjoyment ?? '—' }}/100

Records

{{ cardSelected.percentToQualify }}% or better to qualify

{{ cardSelected.percentToQualify }}% or better to qualify

100% to qualify

{{ record.percent }}%

{{ record.user }} Mobile

Sort by

Filter

{{ filteredList.filter(([,r]) => r != null && r <= 150).length }} levels · including legacy
The List
Legacy List
`, 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, `$1`); }, 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'); }