Files
gd-list/app.js
T
2026-04-01 20:06:06 -05:00

1425 lines
52 KiB
JavaScript

// Lightweight multi-page handler for GD fedl
(function(){
function qs(id){return document.getElementById(id)}
const page = document.body.dataset.page;
const isFileProtocol = window.location.protocol === 'file:';
const liveServerBase = 'https://raspberrypi-1.tail46eacb.ts.net/fedl';
const canUseLiveServer = !isFileProtocol || !!liveServerBase;
const liveApiUrl = `${liveServerBase}/api/list`;
const liveRunsUrl = `${liveServerBase}/api/runs`;
const liveEventsUrl = `${liveServerBase}/events`;
const liveDataFileUrl = `${liveServerBase}/server/data.txt`;
let cachedItems = null;
let cachedRuns = null;
let cachedLevelMeta = null;
let liveBound = false;
let liveHandlers = [];
let runsHandlers = [];
// Storage helpers
function read(key, fallback){
try{const v = localStorage.getItem(key); return v?JSON.parse(v):fallback}
catch(e){return fallback}
}
function write(key, val){localStorage.setItem(key,JSON.stringify(val))}
function parseData(txt){
return txt.split(/\r?\n/).map(l=>l.trim()).filter(Boolean).map(l=>{
const parts = l.split('|').map(p=>p.trim());
return {level:parts[0]||'Unknown',position:parts[1]||'',title:parts[2]||'Untitled',url:parts[3]||''};
});
}
function formatData(items){
return items.map(item=>[
item.level || 'new',
item.position || '',
item.title || '',
item.url || ''
].join('|')).join('\n');
}
function parseLevelMeta(txt){
const map = {};
txt.split(/\r?\n/).map(l=>l.trim()).filter(Boolean).forEach(l=>{
if(l.startsWith('//')) return;
const parts = l.split('|').map(p=>p.trim());
const title = parts[0] || '';
if(!title) return;
map[title] = {
levelId: parts[1] || 'unknown',
percent: parts[2] || '100'
};
});
return map;
}
function loadItems(){
if(cachedItems) return Promise.resolve(cachedItems);
if(!canUseLiveServer){
return fetch('data.txt', {cache:'no-store'}).then(r=>{
if(!r.ok) throw new Error('static data unavailable');
return r.text();
}).then(txt=>{
cachedItems = parseData(txt);
return cachedItems;
});
}
return fetch(liveApiUrl, {cache:'no-store'}).then(r=>{
if(!r.ok) throw new Error('API unavailable');
const contentType = (r.headers.get('content-type') || '').toLowerCase();
if(contentType.includes('application/json')){
return r.json().then(data=>Array.isArray(data.items) ? data.items : []);
}
return r.text().then(txt=>parseData(txt));
}).then(items=>{
cachedItems = items;
return cachedItems;
}).catch(()=>{
return fetch(liveDataFileUrl, {cache:'no-store'}).then(r=>{
if(!r.ok) throw new Error('server data unavailable');
return r.text();
}).then(txt=>{
cachedItems = parseData(txt);
return cachedItems;
}).catch(()=>{
return fetch('data.txt', {cache:'no-store'}).then(r=>{
if(!r.ok) throw new Error('static data unavailable');
return r.text();
}).then(txt=>{
cachedItems = parseData(txt);
return cachedItems;
});
});
});
}
function clearItemsCache(){
cachedItems = null;
}
function loadRuns(){
if(cachedRuns) return Promise.resolve(cachedRuns);
if(!canUseLiveServer){
cachedRuns = [];
return Promise.resolve(cachedRuns);
}
return fetch(liveRunsUrl, {cache:'no-store'}).then(r=>{
if(!r.ok) throw new Error('Runs API unavailable');
return r.json();
}).then(data=>{
cachedRuns = Array.isArray(data.items) ? data.items : [];
return cachedRuns;
});
}
function clearRunsCache(){
cachedRuns = null;
}
function onLiveUpdate(handler){
liveHandlers.push(handler);
}
function notifyLiveUpdate(items){
liveHandlers.forEach(handler=>handler(items));
}
function onRunsUpdate(handler){
runsHandlers.push(handler);
}
function notifyRunsUpdate(runs){
runsHandlers.forEach(handler=>handler(runs));
}
function refreshItems(){
clearItemsCache();
return loadItems().then(items=>{
notifyLiveUpdate(items);
return items;
});
}
function refreshRuns(){
clearRunsCache();
return loadRuns().then(runs=>{
notifyRunsUpdate(runs);
return runs;
});
}
function bindLiveUpdates(){
if(liveBound || !canUseLiveServer || typeof window.EventSource === 'undefined') return;
liveBound = true;
const source = new EventSource(liveEventsUrl);
source.addEventListener('list-update', ()=>{
refreshItems().catch(err=>console.error(err));
});
source.addEventListener('runs-update', ()=>{
refreshRuns().catch(err=>console.error(err));
});
source.onerror = function(){
source.close();
liveBound = false;
window.setTimeout(bindLiveUpdates, 3000);
};
}
function loadLevelMeta(){
if(cachedLevelMeta) return Promise.resolve(cachedLevelMeta);
return fetch('level-ids.txt').then(r=>r.text()).then(txt=>{
cachedLevelMeta = parseLevelMeta(txt);
return cachedLevelMeta;
}).catch(()=>{
cachedLevelMeta = {};
return cachedLevelMeta;
});
}
function fetchLevelIdFromApi(title){
const url = `https://gdbrowser.com/api/search/${encodeURIComponent(title)}?diff=-2&demonFilter=5&count=10`;
return fetch(url).then(r=>r.json()).then(results=>{
if(!Array.isArray(results) || !results.length) return null;
const exact = results.find(item=>String(item.name||'').toLowerCase() === String(title||'').toLowerCase());
const match = exact || results[0];
if(!match || !match.id) return null;
return String(match.id);
}).catch(()=>null);
}
function renderApprovedRunsForLevel(item, hostEl){
if(!hostEl) return;
hostEl.innerHTML = '<p class="muted">Loading approved runs...</p>';
loadRuns().then(runs=>{
const approvedRuns = runs.filter(run=>{
return String(run.status || '').toLowerCase() === 'approved'
&& String(run.levelTitle || '').toLowerCase() === String(item.title || '').toLowerCase();
});
if(!approvedRuns.length){
hostEl.innerHTML = '<p class="muted">No approved runs have been linked to this level yet.</p>';
return;
}
hostEl.innerHTML = approvedRuns.map(run=>`
<article class="modal-run-card">
<strong>${escapeHtml(run.playerName || 'Unknown player')}</strong>
<span>${escapeHtml(run.percent || '100')}%</span>
<a class="text-link" href="${escapeAttr(run.videoUrl || '#')}" target="_blank" rel="noopener noreferrer">Open run video</a>
</article>
`).join('');
}).catch(err=>{
console.error(err);
hostEl.innerHTML = '<p class="muted">Could not load approved runs for this level.</p>';
});
}
function extractYouTubeID(url){
const m = String(url || '').match(/(?:v=|\/embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/);
return m ? m[1] : '';
}
function openVideoModal(item, options){
const config = Object.assign({showRuns:false}, options || {});
const url = item && item.url;
if(!url) return;
const id = extractYouTubeID(url);
if(!id){
window.open(url,'_blank');
return;
}
let modal = document.querySelector('.video-modal');
if(!modal){
modal = document.createElement('div'); modal.className='video-modal';
const inner = document.createElement('div'); inner.className='inner';
const close = document.createElement('button'); close.textContent='Close'; close.className='btn'; close.style.float='right'; close.onclick=()=>modal.remove();
inner.appendChild(close);
const iframe = document.createElement('iframe'); iframe.allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'; iframe.allowFullscreen=true;
inner.appendChild(iframe); modal.appendChild(inner); document.body.appendChild(modal);
const runsWrap = document.createElement('div'); runsWrap.className = 'modal-runs-wrap';
runsWrap.innerHTML = `
<div class="modal-runs-head">
<strong>Approved runs</strong>
<span class="muted">Player, percent, and linked video</span>
</div>
<div class="modal-runs-list"></div>
`;
inner.appendChild(runsWrap);
}
modal.querySelector('iframe').src = `https://www.youtube.com/embed/${id}`;
const runsWrap = modal.querySelector('.modal-runs-wrap');
const runsList = modal.querySelector('.modal-runs-list');
if(runsWrap) runsWrap.hidden = !config.showRuns;
if(config.showRuns && runsList){
renderApprovedRunsForLevel(item, runsList);
}
modal.style.display = 'flex';
}
if(page==='index'){
const totalEl = qs('hero-total-levels');
const topEl = qs('hero-top-entry');
const approvedRunsEl = qs('hero-last-slot');
const featuredListEl = qs('featured-list');
function renderFeatured(items){
if(!featuredListEl) return;
const rankedItems = items.slice().sort((a,b)=>(Number(a.position)||0)-(Number(b.position)||0));
const featured = rankedItems.slice(0, 3);
if(!featured.length){
featuredListEl.innerHTML = `
<article class="featured-card">
<span class="featured-rank">#--</span>
<strong>No list data found</strong>
<p>The homepage preview could not load any FEDL entries yet.</p>
</article>
`;
return;
}
featuredListEl.innerHTML = featured.map(item=>`
<article class="featured-card">
<span class="featured-rank">#${escapeHtml(item.position || '--')}</span>
<strong>${escapeHtml(item.title || 'Untitled')}</strong>
<p>${item.url ? 'Video link is ready from the list page.' : 'This entry does not have a linked video yet.'}</p>
</article>
`).join('');
}
function renderHome(items){
const rankedItems = items.slice().sort((a,b)=>(Number(a.position)||0)-(Number(b.position)||0));
const firstItem = rankedItems[0];
if(totalEl) totalEl.textContent = String(rankedItems.length || 0);
if(topEl) topEl.textContent = firstItem ? firstItem.title : 'Unavailable';
renderFeatured(rankedItems);
}
function renderApprovedRuns(runs){
if(!approvedRunsEl) return;
const approvedCount = runs.filter(run=>String(run.status || '').toLowerCase() === 'approved').length;
approvedRunsEl.textContent = String(approvedCount);
}
loadItems().then(renderHome).catch(()=>{
renderHome([]);
});
loadRuns().then(renderApprovedRuns).catch(()=>{
renderApprovedRuns([]);
});
bindLiveUpdates();
onLiveUpdate(renderHome);
onRunsUpdate(renderApprovedRuns);
}
if(page==='roulette'){
const spinBtn = qs('roulette-spin');
const statusEl = qs('roulette-status');
const titleEl = qs('roulette-title');
const rankEl = qs('roulette-rank');
const idEl = qs('roulette-level-id');
const noteEl = qs('roulette-note');
const openEl = qs('roulette-open');
function showPick(item, meta){
statusEl.textContent = 'Your demon is:';
titleEl.textContent = item.title;
rankEl.textContent = `Rank: #${item.position}`;
idEl.textContent = `Level ID: ${meta.levelId || 'unknown'}`;
noteEl.textContent = meta.source === 'api'
? 'Level ID was looked up from the Geometry Dash community API.'
: 'Level ID came from your local level-ids.txt file.';
if(item.url){
openEl.hidden = false;
openEl.href = '#';
openEl.onclick = function(e){
e.preventDefault();
openVideoModal(item, {showRuns:false});
};
}else{
openEl.hidden = true;
openEl.onclick = null;
}
}
spinBtn.addEventListener('click', ()=>{
statusEl.textContent = 'Spinning...';
titleEl.textContent = 'Choosing a demon';
rankEl.textContent = 'Rank: -';
idEl.textContent = 'Level ID: -';
noteEl.textContent = 'Checking your local file and API if needed.';
openEl.hidden = true;
Promise.all([loadItems(), loadLevelMeta()]).then(([items, metaMap])=>{
if(!items.length){
statusEl.textContent = 'No demons found.';
titleEl.textContent = 'Add demons to the live server list';
idEl.textContent = 'Level ID: -';
noteEl.textContent = 'No list data was found.';
return;
}
const item = items[Math.floor(Math.random()*items.length)];
const localMeta = metaMap[item.title] || {levelId:'unknown', percent:'100'};
if(localMeta.levelId && localMeta.levelId !== 'unknown'){
window.setTimeout(()=>showPick(item, {levelId: localMeta.levelId, percent: localMeta.percent, source: 'file'}), 350);
return;
}
fetchLevelIdFromApi(item.title).then(levelId=>{
const meta = {
levelId: levelId || 'unknown',
percent: localMeta.percent || '100',
source: levelId ? 'api' : 'file'
};
window.setTimeout(()=>showPick(item, meta), 350);
});
}).catch(err=>{
statusEl.textContent = 'Could not load the list.';
titleEl.textContent = 'Run the site on a local server';
rankEl.textContent = 'Rank: -';
idEl.textContent = 'Level ID: -';
noteEl.textContent = 'The live list or API lookup failed.';
console.error(err);
});
});
}
if(page==='guess'){
const modeSelect = qs('guess-mode');
const startBtn = qs('guess-start');
const form = qs('guess-form');
const input = qs('guess-input');
const statusEl = qs('guess-status');
const titleEl = qs('guess-level-title');
const attemptsEl = qs('guess-attempts');
const feedbackEl = qs('guess-feedback');
const answerEl = qs('guess-answer');
const openEl = qs('guess-open');
const guessModes = {
casual: {label:'Casual', tries:6},
standard: {label:'Standard', tries:4},
hard: {label:'Hard', tries:3},
marathon: {label:'Marathon', tries:8}
};
const state = {
active: false,
triesLeft: guessModes.standard.tries,
answer: null,
item: null
};
function getRankedItems(items){
return items.slice().filter(item=>Number(item.position) > 0).sort((a,b)=>(Number(a.position)||0)-(Number(b.position)||0));
}
function getSelectedMode(){
return guessModes[(modeSelect && modeSelect.value) || 'standard'] || guessModes.standard;
}
function resetGuessUi(message){
const mode = getSelectedMode();
state.active = false;
state.triesLeft = mode.tries;
state.answer = null;
state.item = null;
statusEl.textContent = message;
titleEl.textContent = 'No level selected';
attemptsEl.textContent = `Tries left: ${mode.tries}`;
feedbackEl.textContent = `Mode: ${mode.label}. Enter a rank number to start guessing.`;
answerEl.textContent = 'The correct rank will show here if you run out of guesses.';
openEl.hidden = true;
openEl.href = '#';
openEl.onclick = null;
input.value = '';
}
function finishRound(message, revealAnswer){
state.active = false;
statusEl.textContent = message;
attemptsEl.textContent = `Tries left: ${state.triesLeft}`;
answerEl.textContent = revealAnswer
? `${state.item.title} is ranked #${state.answer}.`
: 'Correct. Start another round whenever you want.';
if(state.item && state.item.url){
openEl.hidden = false;
openEl.href = '#';
openEl.onclick = function(e){
e.preventDefault();
openVideoModal(state.item, {showRuns:false});
};
}
}
function startRound(){
const mode = getSelectedMode();
statusEl.textContent = 'Picking a level...';
attemptsEl.textContent = `Tries left: ${mode.tries}`;
feedbackEl.textContent = `Loading a ${mode.label.toLowerCase()} round.`;
answerEl.textContent = 'You will get hints after each wrong guess.';
openEl.hidden = true;
openEl.onclick = null;
input.value = '';
loadItems().then(items=>{
const rankedItems = getRankedItems(items);
if(!rankedItems.length){
resetGuessUi('No ranked levels were found.');
feedbackEl.textContent = 'Add list data first, then start another round.';
return;
}
const item = rankedItems[Math.floor(Math.random() * rankedItems.length)];
state.active = true;
state.triesLeft = mode.tries;
state.answer = Number(item.position);
state.item = item;
statusEl.textContent = 'Guess this level\'s rank.';
titleEl.textContent = item.title;
attemptsEl.textContent = `Tries left: ${mode.tries}`;
feedbackEl.textContent = `Mode: ${mode.label}. Guess the rank and I will tell you higher or lower.`;
answerEl.textContent = 'The correct rank will show here if you run out of guesses.';
input.value = '';
input.focus();
}).catch(err=>{
console.error(err);
resetGuessUi('Could not load the list for the guessing game.');
feedbackEl.textContent = 'Try again after the list finishes loading.';
});
}
function submitGuess(){
if(!state.active || !state.item){
feedbackEl.textContent = 'Start a round first so there is a level to guess.';
return;
}
const rawGuess = input.value.trim();
const guess = Number(rawGuess);
if(!rawGuess || !Number.isInteger(guess) || guess < 1){
feedbackEl.textContent = 'Enter a valid whole-number rank.';
return;
}
if(guess === state.answer){
feedbackEl.textContent = `Correct. ${state.item.title} is #${state.answer}.`;
finishRound('You got it.', false);
return;
}
state.triesLeft -= 1;
attemptsEl.textContent = `Tries left: ${state.triesLeft}`;
const direction = guess < state.answer ? 'Higher' : 'Lower';
if(state.triesLeft > 0){
feedbackEl.textContent = `${direction}. #${guess} is not the right spot.`;
return;
}
feedbackEl.textContent = `${direction}. That was your last guess.`;
finishRound('Round over.', true);
}
resetGuessUi('Start a round to get a level.');
if(modeSelect){
modeSelect.addEventListener('change', ()=>{
if(!state.active) resetGuessUi('Start a round to get a level.');
});
}
startBtn.addEventListener('click', startRound);
form.addEventListener('submit', function(e){
e.preventDefault();
submitGuess();
});
}
// Players page
if(page==='players'){
const playersArea = qs('players-area');
const searchEl = qs('search');
const filterSelect = qs('group-filter');
const groupsEl = qs('player-groups');
if(!playersArea || !searchEl || !filterSelect || !groupsEl) return;
let players = [];
function getGroupKey(name){
const first = String(name || '').trim().charAt(0).toUpperCase();
return first.match(/[A-Z0-9]/) ? first : '#';
}
function computeGroups(items){
const set = new Set();
items.forEach(item => set.add(getGroupKey(item.name)));
return Array.from(set).sort((a,b)=> a === '#' ? 1 : b === '#' ? -1 : a.localeCompare(b));
}
function setupGroups(items){
const groups = computeGroups(items);
groupsEl.innerHTML = '';
filterSelect.innerHTML = '<option value="all">All players</option>';
groups.forEach(group => {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = group;
btn.className = 'level-link';
btn.addEventListener('click', () => {
filterSelect.value = group;
renderTable();
groupsEl.querySelectorAll('.level-link').forEach(el=>el.classList.remove('active'));
btn.classList.add('active');
});
li.appendChild(btn);
groupsEl.appendChild(li);
const opt = document.createElement('option');
opt.value = group;
opt.textContent = group;
filterSelect.appendChild(opt);
});
}
function buildPlayers(runs, listItems){
const lookup = new Map(listItems.map(item => [String(item.title || '').toLowerCase(), Number(item.position) || 9999]));
const map = new Map();
runs.filter(run => String(run.status || '').toLowerCase() === 'approved').forEach(run => {
const playerName = String(run.playerName || '').trim();
if(!playerName) return;
const key = playerName.toLowerCase();
let entry = map.get(key);
if(!entry){
entry = {name: playerName, runs: 0, bestRank: 9999, topLevels: new Set()};
map.set(key, entry);
}
entry.runs += 1;
const rank = lookup.get(String(run.levelTitle || '').toLowerCase()) || 9999;
if(rank > 0 && rank < entry.bestRank) entry.bestRank = rank;
if(run.levelTitle) entry.topLevels.add(String(run.levelTitle).trim());
});
return Array.from(map.values()).map(entry => ({
name: entry.name,
runs: entry.runs,
bestRank: entry.bestRank === 9999 ? '—' : `#${entry.bestRank}`,
topLevels: Array.from(entry.topLevels).slice(0, 3).join(', ')
})).sort((a,b) => {
if(b.runs !== a.runs) return b.runs - a.runs;
const aRank = typeof a.bestRank === 'string' ? Number(a.bestRank.slice(1)) || 9999 : a.bestRank;
const bRank = typeof b.bestRank === 'string' ? Number(b.bestRank.slice(1)) || 9999 : b.bestRank;
if(aRank !== bRank) return aRank - bRank;
return a.name.localeCompare(b.name);
});
}
function renderTable(){
const query = String(searchEl.value || '').toLowerCase().trim();
const filterValue = filterSelect.value || 'all';
const filtered = players.filter(item => {
if(filterValue !== 'all' && getGroupKey(item.name) !== filterValue) return false;
if(!query) return true;
return item.name.toLowerCase().includes(query);
});
playersArea.innerHTML = '';
if(!filtered.length){
playersArea.innerHTML = '<tr><td colspan="4" class="muted">No server players found.</td></tr>';
return;
}
filtered.forEach(item => {
const tr = document.createElement('tr');
const tdName = document.createElement('td'); tdName.textContent = item.name;
const tdRuns = document.createElement('td'); tdRuns.textContent = String(item.runs);
const tdRank = document.createElement('td'); tdRank.textContent = item.bestRank;
const tdLevels = document.createElement('td'); tdLevels.textContent = item.topLevels;
tr.appendChild(tdName);
tr.appendChild(tdRuns);
tr.appendChild(tdRank);
tr.appendChild(tdLevels);
playersArea.appendChild(tr);
});
}
function showLoading(){
playersArea.innerHTML = '<tr><td colspan="4" class="muted">Loading player stats from server...</td></tr>';
}
function syncView(newPlayers){
players = newPlayers;
setupGroups(players);
renderTable();
}
function loadPlayerStats(){
showLoading();
return Promise.all([loadRuns(), loadItems()])
.then(([runs, items]) => {
const computed = buildPlayers(runs, items);
syncView(computed);
})
.catch(err => {
console.error(err);
playersArea.innerHTML = '<tr><td colspan="4" class="muted">Could not load server player stats.</td></tr>';
});
}
searchEl.addEventListener('input', renderTable);
filterSelect.addEventListener('change', () => {
const activeBtn = Array.from(groupsEl.querySelectorAll('.level-link')).find(btn => btn.textContent === filterSelect.value);
groupsEl.querySelectorAll('.level-link').forEach(btn => btn.classList.toggle('active', btn === activeBtn));
renderTable();
});
loadPlayerStats();
bindLiveUpdates();
onRunsUpdate(() => {
loadPlayerStats();
});
onLiveUpdate(() => {
loadPlayerStats();
});
}
function initListPage(){
const levelsEl = qs('levels'); const listArea = qs('list-area'); const titleEl = qs('list-title');
const searchEl = qs('search');
const filterSelect = qs('level-filter');
let currentItems = [];
let controlsBound = false;
// Load hard-coded data file data.txt (category|position|title|url per line)
function loadData(){
loadItems().then(items=>{
applyItems(items);
}).catch(err=>{listArea.innerHTML='<p class="muted">Failed to load list data - run via the Node server.</p>'; console.error(err)});
}
function computeCategories(items){
const max = items.reduce((m,it)=>Math.max(m, Number(it.position)||0), 0);
const cats = ['Full List'];
for(let i=1;i<=max;i+=10){
const start = i; const end = Math.min(i+9, max);
cats.push(`Top ${start}-${end}`);
}
return cats;
}
function setupLevels(items){
const categories = computeCategories(items);
levelsEl.innerHTML='';
filterSelect.innerHTML = '<option value="all">Full List</option>';
categories.forEach(cat=>{
const li = document.createElement('li');
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = cat;
btn.className = 'level-link';
btn.addEventListener('click', ()=> {
selectLevel(cat, items, btn);
filterSelect.value = cat;
});
li.appendChild(btn);
levelsEl.appendChild(li);
const opt = document.createElement('option'); opt.value = cat; opt.textContent = cat; filterSelect.appendChild(opt);
});
if(!controlsBound){
searchEl.addEventListener('input', ()=> renderTable(currentItems));
filterSelect.addEventListener('change', ()=> renderTable(currentItems));
controlsBound = true;
}
}
function selectLevel(level, items, linkEl){
levelsEl.querySelectorAll('.level-link').forEach(a=>a.classList.remove('active'));
if(linkEl) linkEl.classList.add('active');
qs('level-filter').value = level;
renderTable(items);
}
function renderTable(items){
const q = (searchEl && searchEl.value || '').toLowerCase();
const levelFilter = (filterSelect && filterSelect.value) || 'all';
const filtered = items.filter(it=>{
// apply level / category filter (support range categories like "Top 1-10")
if(levelFilter && levelFilter!=='all' && levelFilter!=='Full List'){
const m = levelFilter.match(/Top\s*(\d+)-(\d+)/i);
if(m){
const s = Number(m[1]); const e = Number(m[2]); const pos = Number(it.position)||0;
if(pos < s || pos > e) return false;
}
}
if(!q) return true;
return (it.title||'').toLowerCase().includes(q) || (it.level||'').toLowerCase().includes(q);
}).sort((a,b)=> (Number(a.position)||0)-(Number(b.position)||0));
const tbody = qs('list-area'); tbody.innerHTML='';
filtered.forEach(it=>{
const tr = document.createElement('tr');
const tdNum = document.createElement('td'); tdNum.textContent = it.position;
const tdTitle = document.createElement('td'); tdTitle.textContent = it.title;
const tdAct = document.createElement('td');
const a = document.createElement('a'); a.textContent='Open'; a.href='#'; a.className='btn';
a.addEventListener('click', (e)=>{e.preventDefault(); openVideoModal(it, {showRuns:true})});
tdAct.appendChild(a);
tr.appendChild(tdNum); tr.appendChild(tdTitle); tr.appendChild(tdAct);
tbody.appendChild(tr);
});
}
function applyItems(items){
const previousFilter = filterSelect.value || 'all';
currentItems = items.slice();
setupLevels(currentItems);
const availableFilters = Array.from(filterSelect.options).map(option=>option.value);
filterSelect.value = availableFilters.includes(previousFilter) ? previousFilter : 'all';
const activeText = filterSelect.value === 'all' ? 'Full List' : filterSelect.value;
levelsEl.querySelectorAll('.level-link').forEach(btn=>{
btn.classList.toggle('active', btn.textContent === activeText);
});
renderTable(currentItems);
}
loadData();
return {applyItems};
}
// Lists page
if(page==='lists'){
const listPage = initListPage();
bindLiveUpdates();
onLiveUpdate(function(updatedItems){
listPage.applyItems(updatedItems);
});
}
if(page==='admelist'){
const loginScreenEl = qs('admin-login-screen');
const adminShellContentEl = qs('admin-shell-content');
const loginFormEl = qs('admin-login-form');
const statusEl = qs('admin-status');
const listTbody = qs('admin-list-body');
const addBtn = qs('add-row');
const saveBtn = qs('save-list');
const searchEl = qs('admin-search');
const adminPasswordEl = qs('admin-password');
const authStatusEl = qs('admin-auth-status');
const runsStatusEl = qs('runs-admin-status');
const runsTbody = qs('run-admin-body');
const runSearchEl = qs('run-search');
const importStatusEl = qs('import-status');
const importPointercrateBtn = qs('import-pointercrate');
const importAredlBtn = qs('import-aredl');
let items = [];
let runs = [];
const adminPasswordKey = 'fedl_admin_password';
function getAdminPassword(){
try{return sessionStorage.getItem(adminPasswordKey) || '';}
catch(e){return '';}
}
function setAdminPassword(password){
try{
if(password) sessionStorage.setItem(adminPasswordKey, password);
else sessionStorage.removeItem(adminPasswordKey);
}catch(e){}
if(adminPasswordEl) adminPasswordEl.value = password;
if(authStatusEl){
authStatusEl.textContent = password
? 'Password saved for this browser session.'
: 'Saved only in this browser session.';
if(password) authStatusEl.classList.remove('error-text');
}
}
function authHeaders(extraHeaders){
const headers = Object.assign({}, extraHeaders || {});
const password = getAdminPassword();
if(password){
headers.Authorization = `Basic ${btoa(`fedl:${password}`)}`;
}
return headers;
}
function handleAdminAuthFailure(message, targetSetter){
setAdminPassword('');
document.body.classList.add('admin-locked');
if(adminShellContentEl) adminShellContentEl.hidden = true;
if(loginScreenEl) loginScreenEl.hidden = false;
targetSetter(message || 'Admin password required or incorrect.', true);
}
function unlockAdminShell(){
document.body.classList.remove('admin-locked');
if(loginScreenEl) loginScreenEl.hidden = true;
if(adminShellContentEl) adminShellContentEl.hidden = false;
}
function verifyAdminPassword(){
if(!getAdminPassword()){
handleAdminAuthFailure('Enter the admin password to continue.', function(message, isError){
if(!authStatusEl) return;
authStatusEl.textContent = message;
authStatusEl.classList.toggle('error-text', !!isError);
});
return Promise.resolve(false);
}
return fetch(`${liveRunsUrl}/__authcheck__`, {
method:'DELETE',
headers:authHeaders()
}).then(r=>{
if(r.status === 401) throw new Error('Admin auth failed');
if(r.status !== 404) throw new Error('Admin verify failed');
return true;
}).then(ok=>{
unlockAdminShell();
if(authStatusEl){
authStatusEl.textContent = 'Access granted for this browser session.';
authStatusEl.classList.remove('error-text');
}
loadAdmin();
loadRunsAdmin();
return ok;
}).catch(err=>{
console.error(err);
handleAdminAuthFailure('Wrong admin password. Try again.', function(message, isError){
if(!authStatusEl) return;
authStatusEl.textContent = message;
authStatusEl.classList.toggle('error-text', !!isError);
});
return false;
});
}
function setStatus(message, isError){
if(!statusEl) return;
statusEl.textContent = message;
statusEl.classList.toggle('error-text', !!isError);
}
function setRunsStatus(message, isError){
if(!runsStatusEl) return;
runsStatusEl.textContent = message;
runsStatusEl.classList.toggle('error-text', !!isError);
}
function filteredItems(){
const query = (searchEl && searchEl.value || '').trim().toLowerCase();
if(!query) return items;
return items.filter(item=>{
return [item.level, item.position, item.title, item.url].some(value=>
String(value || '').toLowerCase().includes(query)
);
});
}
function normalizePositions(){
let position = 1;
items.forEach(item=>{
if(item._isDraft){
item.position = '';
return;
}
item.position = String(position);
position += 1;
});
}
function moveItemToPosition(index, rawPosition){
if(!items[index]) return;
const parsedPosition = Number(rawPosition);
if(!Number.isFinite(parsedPosition) || parsedPosition < 1) return;
const nextPosition = Math.max(1, parsedPosition);
const [item] = items.splice(index, 1);
item._isDraft = false;
const drafts = items.filter(entry=>entry._isDraft);
const ranked = items.filter(entry=>!entry._isDraft);
const targetIndex = Math.min(ranked.length, nextPosition - 1);
ranked.splice(targetIndex, 0, item);
items = drafts.concat(ranked);
normalizePositions();
}
function renderAdminTable(){
const rows = filteredItems();
listTbody.innerHTML = '';
rows.forEach(item=>{
const actualIndex = items.indexOf(item);
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input data-field="position" data-index="${actualIndex}" type="number" min="1" value="${escapeAttr(item.position)}"></td>
<td><input data-field="level" data-index="${actualIndex}" type="text" value="${escapeAttr(item.level)}"></td>
<td><input data-field="title" data-index="${actualIndex}" type="text" value="${escapeAttr(item.title)}"></td>
<td><input data-field="url" data-index="${actualIndex}" type="url" value="${escapeAttr(item.url)}"></td>
<td><button type="button" class="btn danger-btn small-btn" data-delete="${actualIndex}">Delete</button></td>
`;
listTbody.appendChild(tr);
});
if(!rows.length){
const tr = document.createElement('tr');
tr.innerHTML = '<td colspan="5" class="muted">No rows match your search.</td>';
listTbody.appendChild(tr);
}
}
function filteredRuns(){
const query = (runSearchEl && runSearchEl.value || '').trim().toLowerCase();
if(!query) return runs;
return runs.filter(run=>{
return [
run.status,
run.playerName,
run.levelTitle,
run.videoUrl,
run.rawFootageUrl,
run.notes,
run.reviewNotes
].some(value=>String(value || '').toLowerCase().includes(query));
});
}
function formatDate(value){
if(!value) return 'Unknown';
const date = new Date(value);
if(Number.isNaN(date.getTime())) return 'Unknown';
return date.toLocaleString();
}
function renderRunsTable(){
const rows = filteredRuns();
runsTbody.innerHTML = '';
rows.forEach(run=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td><span class="status-pill status-${escapeAttr(run.status || 'pending')}">${escapeHtml(run.status || 'pending')}</span></td>
<td><strong>${escapeHtml(run.playerName || 'Unknown')}</strong></td>
<td><strong>${escapeHtml(run.percent || '100')}%</strong></td>
<td>
<div class="run-admin-cell">
<strong>${escapeHtml(run.levelTitle || 'Untitled')}</strong>
<span class="muted small">${escapeHtml(run.notes || 'No submission notes.')}</span>
</div>
</td>
<td>${escapeHtml(formatDate(run.submittedAt))}</td>
<td>
<div class="run-admin-actions">
<a class="btn ghost-btn small-btn" href="${escapeAttr(run.videoUrl || '#')}" target="_blank" rel="noopener noreferrer">Video</a>
<button type="button" class="btn ghost-btn small-btn" data-run-action="approved" data-run-id="${escapeAttr(run.id)}">Approve</button>
<button type="button" class="btn ghost-btn small-btn" data-run-action="rejected" data-run-id="${escapeAttr(run.id)}">Reject</button>
<button type="button" class="btn danger-btn small-btn" data-run-delete="${escapeAttr(run.id)}">Delete</button>
</div>
</td>
`;
runsTbody.appendChild(tr);
const detailRow = document.createElement('tr');
detailRow.className = 'run-admin-detail-row';
detailRow.innerHTML = `
<td colspan="6">
<div class="run-admin-detail">
<span><strong>Raw:</strong> ${run.rawFootageUrl ? `<a href="${escapeAttr(run.rawFootageUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(run.rawFootageUrl)}</a>` : 'None provided'}</span>
<span><strong>Percent:</strong> ${escapeHtml(run.percent || '100')}%</span>
<span><strong>Reviewed by:</strong> ${escapeHtml(run.reviewedBy || 'Unassigned')}</span>
<span><strong>Review notes:</strong> ${escapeHtml(run.reviewNotes || 'No review notes yet.')}</span>
</div>
</td>
`;
runsTbody.appendChild(detailRow);
});
if(!rows.length){
const tr = document.createElement('tr');
tr.innerHTML = '<td colspan="6" class="muted">No run submissions match your search.</td>';
runsTbody.appendChild(tr);
}
}
function saveItems(){
if(!canUseLiveServer){
setStatus('Start the Node server to save the live list.', true);
return Promise.resolve();
}
const hasUnplacedDraft = items.some(item=>{
const hasContent = String(item.title || '').trim() || String(item.url || '').trim() || String(item.level || '').trim();
return item._isDraft && hasContent;
});
if(hasUnplacedDraft){
setStatus('Give each new row a number before saving.', true);
return Promise.resolve();
}
items = items
.map(item=>({
level: String(item.level || '').trim() || 'new',
position: String(item.position || '').trim(),
title: String(item.title || '').trim(),
url: String(item.url || '').trim()
}))
.filter(item=>item.title);
normalizePositions();
return fetch(liveApiUrl, {
method:'PUT',
headers:authHeaders({'Content-Type':'application/json'}),
body: JSON.stringify({text: formatData(items)})
}).then(r=>{
if(r.status === 401) throw new Error('Admin auth failed');
if(!r.ok) throw new Error('Save failed');
clearItemsCache();
renderAdminTable();
setStatus('Saved. Live pages update automatically.');
}).catch(err=>{
console.error(err);
if(String(err && err.message || '') === 'Admin auth failed'){
handleAdminAuthFailure('Wrong admin password. Enter it above, then try again.', setStatus);
return;
}
setStatus('Could not save. Check the live server endpoint.', true);
});
}
function loadAdmin(){
loadItems().then(loaded=>{
items = loaded.slice().sort((a,b)=>(Number(a.position) || 0) - (Number(b.position) || 0)).map(item=>({
level: item.level,
position: item.position,
title: item.title,
url: item.url,
_isDraft: false
}));
normalizePositions();
renderAdminTable();
setStatus('Connected to live list data.');
}).catch(err=>{
console.error(err);
setStatus('Could not load list data.', true);
});
}
function loadRunsAdmin(){
loadRuns().then(loadedRuns=>{
runs = loadedRuns.slice().sort((a,b)=>new Date(b.submittedAt) - new Date(a.submittedAt));
renderRunsTable();
setRunsStatus('Connected to live run submissions.');
}).catch(err=>{
console.error(err);
setRunsStatus('Could not load run submissions.', true);
});
}
function updateRunStatus(runId, status){
if(!canUseLiveServer){
setRunsStatus('Start the Node server to review submissions.', true);
return;
}
const run = runs.find(entry=>entry.id === runId);
if(!run) return;
const reviewNotes = window.prompt(`Review notes for ${run.levelTitle} (${status})`, run.reviewNotes || '');
if(reviewNotes === null) return;
fetch(`${liveRunsUrl}/${encodeURIComponent(runId)}`, {
method:'PUT',
headers:authHeaders({'Content-Type':'application/json'}),
body: JSON.stringify({
...run,
status,
reviewNotes,
reviewedBy:'FEDL Admin'
})
}).then(r=>{
if(r.status === 401) throw new Error('Admin auth failed');
if(!r.ok) throw new Error('Run update failed');
clearRunsCache();
setRunsStatus(`Run marked ${status}.`);
return refreshRuns();
}).catch(err=>{
console.error(err);
if(String(err && err.message || '') === 'Admin auth failed'){
handleAdminAuthFailure('Wrong admin password. Enter it above to review runs.', setRunsStatus);
return;
}
setRunsStatus('Could not update that run.', true);
});
}
function deleteRun(runId){
if(!canUseLiveServer){
setRunsStatus('Start the Node server to delete submissions.', true);
return;
}
fetch(`${liveRunsUrl}/${encodeURIComponent(runId)}`, {
method:'DELETE',
headers:authHeaders()
}).then(r=>{
if(r.status === 401) throw new Error('Admin auth failed');
if(!r.ok) throw new Error('Run delete failed');
clearRunsCache();
setRunsStatus('Run removed from the queue.');
return refreshRuns();
}).catch(err=>{
console.error(err);
if(String(err && err.message || '') === 'Admin auth failed'){
handleAdminAuthFailure('Wrong admin password. Enter it above to delete runs.', setRunsStatus);
return;
}
setRunsStatus('Could not delete that run.', true);
});
}
listTbody.addEventListener('input', function(event){
const target = event.target;
const field = target.getAttribute('data-field');
const index = Number(target.getAttribute('data-index'));
if(!field || Number.isNaN(index) || !items[index]) return;
if(field === 'position') return;
items[index][field] = target.value;
setStatus('Unsaved changes');
});
listTbody.addEventListener('focusout', function(event){
const target = event.target;
if(!(target instanceof HTMLElement)) return;
const field = target.getAttribute('data-field');
const index = Number(target.getAttribute('data-index'));
if(field !== 'position' || Number.isNaN(index) || !items[index]) return;
moveItemToPosition(index, target.value);
renderAdminTable();
setStatus('Unsaved changes');
});
listTbody.addEventListener('click', function(event){
const deleteButton = event.target.closest('[data-delete]');
if(!deleteButton) return;
const deleteIndex = deleteButton.getAttribute('data-delete');
if(deleteIndex == null) return;
const index = Number(deleteIndex);
if(Number.isNaN(index)) return;
items.splice(index, 1);
normalizePositions();
renderAdminTable();
setStatus('Row removed. Save when ready.');
});
runsTbody.addEventListener('click', function(event){
const actionButton = event.target.closest('[data-run-action]');
if(actionButton){
updateRunStatus(
actionButton.getAttribute('data-run-id'),
actionButton.getAttribute('data-run-action')
);
return;
}
const deleteButton = event.target.closest('[data-run-delete]');
if(deleteButton){
const runId = deleteButton.getAttribute('data-run-delete');
if(runId && window.confirm('Delete this run submission?')){
deleteRun(runId);
}
}
});
addBtn.addEventListener('click', function(){
items.unshift({level:'new', position:'', title:'', url:'', _isDraft:true});
normalizePositions();
renderAdminTable();
setStatus('New row added at the top. Give it a number when you want to place it.');
});
saveBtn.addEventListener('click', function(){
saveItems();
});
if(searchEl){
searchEl.addEventListener('input', renderAdminTable);
}
if(runSearchEl){
runSearchEl.addEventListener('input', renderRunsTable);
}
if(adminPasswordEl){
setAdminPassword(getAdminPassword());
}
function setImportStatus(message, isError){
if(!importStatusEl) return;
importStatusEl.textContent = message;
importStatusEl.classList.toggle('error-text', !!isError);
}
function toggleImportButtons(enabled){
[importPointercrateBtn, importAredlBtn].forEach(btn=>{ if(btn) btn.disabled = !enabled; });
}
function runImport(path, label){
setImportStatus(`Importing ${label} runs...`);
toggleImportButtons(false);
return fetch(path, {
method: 'POST',
headers: authHeaders({'Content-Type':'application/json'})
}).then(async response => {
const payload = await response.json().catch(()=>({}));
if(response.status === 401){
throw new Error('Admin auth failed');
}
if(!response.ok){
throw new Error(payload.error || `Import failed with status ${response.status}`);
}
return payload;
}).then(payload => {
setImportStatus(`${label} import complete. Added ${payload.added || 0} runs, skipped ${payload.skipped || 0}.`);
refreshRuns().catch(()=>{});
}).catch(error => {
console.error(error);
if(error.message === 'Admin auth failed'){
handleAdminAuthFailure('Wrong admin password. Enter it above, then try again.', setImportStatus);
return;
}
setImportStatus(error.message || 'Import failed.', true);
}).finally(()=>{
toggleImportButtons(true);
});
}
if(importPointercrateBtn){
importPointercrateBtn.addEventListener('click', ()=> runImport('/api/import/pointercrate', 'Pointercrate'));
}
if(importAredlBtn){
importAredlBtn.addEventListener('click', ()=> runImport('/api/import/aredl', 'AREDL'));
}
if(loginFormEl){
loginFormEl.addEventListener('submit', function(event){
event.preventDefault();
setAdminPassword((adminPasswordEl && adminPasswordEl.value || '').trim());
if(authStatusEl){
authStatusEl.textContent = 'Checking password...';
authStatusEl.classList.remove('error-text');
}
verifyAdminPassword();
});
}
bindLiveUpdates();
onLiveUpdate(function(updatedItems){
items = updatedItems.slice().sort((a,b)=>(Number(a.position) || 0) - (Number(b.position) || 0)).map(item=>({
level: item.level,
position: item.position,
title: item.title,
url: item.url,
_isDraft: false
}));
normalizePositions();
renderAdminTable();
setStatus('List reloaded from live server.');
});
onRunsUpdate(function(updatedRuns){
runs = updatedRuns.slice().sort((a,b)=>new Date(b.submittedAt) - new Date(a.submittedAt));
renderRunsTable();
setRunsStatus('Run queue reloaded from the live server.');
});
if(getAdminPassword()){
verifyAdminPassword();
}else{
handleAdminAuthFailure('Enter the admin password to continue.', function(message, isError){
if(!authStatusEl) return;
authStatusEl.textContent = message;
authStatusEl.classList.toggle('error-text', !!isError);
});
}
}
if(page==='run'){
const form = qs('run-form');
const formStatusEl = qs('run-form-status');
const listStatusEl = qs('run-list-status');
const submissionsEl = qs('run-submissions');
const levelOptionsEl = qs('run-level-options');
function setRunFormStatus(message, isError){
if(!formStatusEl) return;
formStatusEl.textContent = message;
formStatusEl.classList.toggle('error-text', !!isError);
}
function setRunListStatus(message, isError){
if(!listStatusEl) return;
listStatusEl.textContent = message;
listStatusEl.classList.toggle('error-text', !!isError);
}
function renderRunSubmissions(runs){
submissionsEl.innerHTML = '';
if(!runs.length){
submissionsEl.innerHTML = '<article class="submission-card"><strong>No runs submitted yet</strong><p>The live queue is empty right now.</p></article>';
return;
}
runs.slice(0, 8).forEach(run=>{
const card = document.createElement('article');
card.className = 'submission-card';
card.innerHTML = `
<div class="submission-card-top">
<strong>${escapeHtml(run.levelTitle || 'Untitled')}</strong>
<span class="status-pill status-${escapeAttr(run.status || 'pending')}">${escapeHtml(run.status || 'pending')}</span>
</div>
<p class="submission-meta">By ${escapeHtml(run.playerName || 'Unknown')}${escapeHtml(run.percent || '100')}% • ${escapeHtml(new Date(run.submittedAt).toLocaleString())}</p>
<p>${escapeHtml(run.reviewNotes || run.notes || 'No notes yet.')}</p>
<div class="submission-links">
<a class="text-link" href="${escapeAttr(run.videoUrl || '#')}" target="_blank" rel="noopener noreferrer">Watch run</a>
</div>
`;
submissionsEl.appendChild(card);
});
}
function loadRunPage(){
loadItems().then(items=>{
const titles = items.map(item=>item.title).filter(Boolean);
levelOptionsEl.innerHTML = titles.map(title=>`<option value="${escapeAttr(title)}"></option>`).join('');
}).catch(err=>console.error(err));
loadRuns().then(runs=>{
const sortedRuns = runs.slice().sort((a,b)=>new Date(b.submittedAt) - new Date(a.submittedAt));
renderRunSubmissions(sortedRuns);
setRunListStatus('Live submissions are updating automatically.');
}).catch(err=>{
console.error(err);
renderRunSubmissions([]);
setRunListStatus('Could not load recent submissions.', true);
});
}
form.addEventListener('submit', function(event){
event.preventDefault();
if(!canUseLiveServer){
setRunFormStatus('Start the Node server before submitting runs.', true);
return;
}
const payload = {
playerName: qs('run-player-name').value.trim(),
levelTitle: qs('run-level-title').value.trim(),
videoUrl: qs('run-video-url').value.trim(),
percent: qs('run-percent').value.trim(),
rawFootageUrl: qs('run-raw-footage-url').value.trim(),
notes: qs('run-notes').value.trim()
};
setRunFormStatus('Sending your run to the live queue...');
fetch(liveRunsUrl, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(r=>{
if(!r.ok) throw new Error('Submission failed');
clearRunsCache();
form.reset();
setRunFormStatus('Run submitted. The admin panel can review it now.');
return refreshRuns();
}).catch(err=>{
console.error(err);
setRunFormStatus('Could not submit the run. Check the server and try again.', true);
});
});
bindLiveUpdates();
onRunsUpdate(function(updatedRuns){
const sortedRuns = updatedRuns.slice().sort((a,b)=>new Date(b.submittedAt) - new Date(a.submittedAt));
renderRunSubmissions(sortedRuns);
setRunListStatus('Recent submissions reloaded from the live server.');
});
loadRunPage();
}
// Utility
function escapeHtml(s){return String(s).replace(/[&<>"']/g, c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"})[c])}
function escapeAttr(s){return escapeHtml(String(s == null ? '' : s))}
})();