// 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`; const offlinePage = 'offlineindex.html'; function redirectToOffline(){ if(window.location.pathname.endsWith(`/${offlinePage}`)) return; window.location.replace(offlinePage); } function probeLiveServer(timeoutMs = 5000){ const controller = new AbortController(); const timeoutId = setTimeout(()=>controller.abort(), timeoutMs); return fetch(liveServerBase, { method:'HEAD', cache:'no-store', signal: controller.signal }).then(response => { clearTimeout(timeoutId); return response; }).catch(error => { clearTimeout(timeoutId); throw error; }); } if(!window.location.pathname.endsWith(`/${offlinePage}`)){ probeLiveServer().catch(()=>{ redirectToOffline(); }); } 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 = '

Loading approved runs...

'; 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 = '

No approved runs have been linked to this level yet.

'; return; } hostEl.innerHTML = approvedRuns.map(run=>` `).join(''); }).catch(err=>{ console.error(err); hostEl.innerHTML = '

Could not load approved runs for this level.

'; }); } 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 = ` `; 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 = ` `; return; } featuredListEl.innerHTML = featured.map(item=>` `).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 = ''; 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 = 'No server players found.'; 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 = 'Loading player stats from server...'; } 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 = 'Could not load server player stats.'; }); } 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='

Failed to load list data - run via the Node server.

'; 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 = ''; 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 = ` `; listTbody.appendChild(tr); }); if(!rows.length){ const tr = document.createElement('tr'); tr.innerHTML = 'No rows match your search.'; 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 = ` ${escapeHtml(run.status || 'pending')} ${escapeHtml(run.playerName || 'Unknown')} ${escapeHtml(run.percent || '100')}%
${escapeHtml(run.levelTitle || 'Untitled')} ${escapeHtml(run.notes || 'No submission notes.')}
${escapeHtml(formatDate(run.submittedAt))}
Video
`; runsTbody.appendChild(tr); const detailRow = document.createElement('tr'); detailRow.className = 'run-admin-detail-row'; detailRow.innerHTML = `
Raw: ${run.rawFootageUrl ? `${escapeHtml(run.rawFootageUrl)}` : 'None provided'} Percent: ${escapeHtml(run.percent || '100')}% Reviewed by: ${escapeHtml(run.reviewedBy || 'Unassigned')} Review notes: ${escapeHtml(run.reviewNotes || 'No review notes yet.')}
`; runsTbody.appendChild(detailRow); }); if(!rows.length){ const tr = document.createElement('tr'); tr.innerHTML = 'No run submissions match your search.'; 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 = '
No runs submitted yet

The live queue is empty right now.

'; return; } runs.slice(0, 8).forEach(run=>{ const card = document.createElement('article'); card.className = 'submission-card'; card.innerHTML = `
${escapeHtml(run.levelTitle || 'Untitled')} ${escapeHtml(run.status || 'pending')}

By ${escapeHtml(run.playerName || 'Unknown')} • ${escapeHtml(run.percent || '100')}% • ${escapeHtml(new Date(run.submittedAt).toLocaleString())}

${escapeHtml(run.reviewNotes || run.notes || 'No notes yet.')}

`; submissionsEl.appendChild(card); }); } function loadRunPage(){ loadItems().then(items=>{ const titles = items.map(item=>item.title).filter(Boolean); levelOptionsEl.innerHTML = titles.map(title=>``).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=>({"&":"&","<":"<",">":">","\"":""","'":"'"})[c])} function escapeAttr(s){return escapeHtml(String(s == null ? '' : s))} })();