// 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 TESTING_MODE = false; const liveServerBase = TESTING_MODE ? 'http://127.0.0.1:8090/fedl' : 'https://server.fedl.site/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 MOD_USERS = ['wolf_reaper90','dioxyx','steve']; const SPA_PAGE_KEY = 'onepage'; /** Use for POST /api/import/* and any path under the same base as list/runs (not root-relative /api/...). */ function liveApiPath(path){ const p = String(path || '').startsWith('/') ? path : `/${path}`; return `${liveServerBase}${p}`; } 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; }); } const pagesNeedingLiveStatus = new Set(['index', 'run', 'messages', 'contact', 'signup', 'login', 'account', 'reset-password', 'admelist']); if(pagesNeedingLiveStatus.has(page) && !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 initTheme(){ const themes = { dark: { '--bg': '#0f1724', '--panel': '#071326', '--accent': '#5cc5ff', '--muted': '#9fb3c8', '--text': '#e6eef8', '--card': '#081220', '--accent-warm': '#ffb84d' }, light: { '--bg': '#f0f4f8', '--panel': '#e2e8f0', '--accent': '#0284c7', '--muted': '#64748b', '--text': '#1e293b', '--card': '#cbd5e1', '--accent-warm': '#f59e0b' }, blue: { '--bg': '#0d1b2a', '--panel': '#1b3a5f', '--accent': '#38bdf8', '--muted': '#94a3b8', '--text': '#e0f2fe', '--card': '#142d4c', '--accent-warm': '#fbbf24' }, midnight: { '--bg': '#0a0a12', '--panel': '#12121f', '--accent': '#a78bfa', '--muted': '#6b7280', '--text': '#e5e7eb', '--card': '#0f0f1a', '--accent-warm': '#f472b6' }, cyberpunk: { '--bg': '#0f0f1a', '--panel': '#1a0a2e', '--accent': '#00ff9f', '--muted': '#b388ff', '--text': '#e0f7fa', '--card': '#150f25', '--accent-warm': '#ff00a8' }, earth: { '--bg': '#1a2f1a', '--panel': '#2d4a2d', '--accent': '#84cc16', '--muted': '#a3c9a3', '--text': '#ecfccb', '--card': '#223d22', '--accent-warm': '#fbbf24' }, retro: { '--bg': '#1a1208', '--panel': '#2b1a0a', '--accent': '#ff9f1c', '--muted': '#c9a66b', '--text': '#ffe4b5', '--card': '#241809', '--accent-warm': '#ff6b35' }, matrix: { '--bg': '#000a00', '--panel': '#001100', '--accent': '#00ff00', '--muted': '#00aa00', '--text': '#00ff00', '--card': '#001100', '--accent-warm': '#88ff88' }, synthwave: { '--bg': '#1a0a2e', '--panel': '#2d1b4e', '--accent': '#ff2a6d', '--muted': '#c792ea', '--text': '#f4e9ff', '--card': '#251440', '--accent-warm': '#05d9e8' }, fire: { '--bg': '#1a0505', '--panel': '#2d0a0a', '--accent': '#ff4500', '--muted': '#cc5500', '--text': '#ffd4b8', '--card': '#250a0a', '--accent-warm': '#ffaa00' }, galaxy: { '--bg': '#0a0612', '--panel': '#150f25', '--accent': '#e056fd', '--muted': '#7c3aed', '--text': '#f0e6ff', '--card': '#0f0818', '--accent-warm': '#f9ca24' }, candy: { '--bg': '#fdf2f8', '--panel': '#fce7f3', '--accent': '#f472b6', '--muted': '#94a3b8', '--text': '#831843', '--card': '#fbcfe8', '--accent-warm': '#34d399' }, highcontrast: { '--bg': '#000000', '--panel': '#111111', '--accent': '#ffffff', '--muted': '#cccccc', '--text': '#ffffff', '--card': '#0a0a0a', '--accent-warm': '#ffff00' }, original: { '--bg': '#0f1724', '--panel': '#071326', '--accent': '#5cc5ff', '--muted': '#9fb3c8', '--text': '#e6eef8', '--card': '#081220', '--accent-warm': '#ffb84d' } }; const saved = localStorage.getItem('fedl_theme') || 'dark'; const vars = themes[saved]; if (vars) { const root = document.documentElement; Object.entries(vars).forEach(([k, v]) => root.style.setProperty(k, v)); document.body.dataset.theme = saved; } try { const activeId = localStorage.getItem('fedl_user_account_active'); if (activeId) { const userData = JSON.parse(localStorage.getItem('fedl_user_data_' + activeId)); if (userData && userData.theme) { const themeName = userData.theme; const accountVars = themes[themeName]; if (accountVars) { document.documentElement.style.setProperty('--bg', accountVars['--bg']); document.documentElement.style.setProperty('--panel', accountVars['--panel']); document.documentElement.style.setProperty('--accent', accountVars['--accent']); document.documentElement.style.setProperty('--muted', accountVars['--muted']); document.documentElement.style.setProperty('--text', accountVars['--text']); document.documentElement.style.setProperty('--card', accountVars['--card']); document.documentElement.style.setProperty('--accent-warm', accountVars['--accent-warm']); document.body.dataset.theme = themeName; localStorage.setItem('fedl_theme', themeName); } } } } catch(e) {} })(); function debounce(fn, wait){ let timeoutId = null; return function(){ const ctx = this; const args = arguments; clearTimeout(timeoutId); timeoutId = setTimeout(()=>fn.apply(ctx, args), wait); }; } const FEDL_USER_ACCOUNTS = 'fedl_user_accounts'; const FEDL_USER_ACCOUNT_ACTIVE = 'fedl_user_account_active'; function fedlAccountId(){ try { return localStorage.getItem(FEDL_USER_ACCOUNT_ACTIVE) || ''; } catch (e) { return ''; } } function fedlSetActiveAccountId(id){ try { if (id) { localStorage.setItem(FEDL_USER_ACCOUNT_ACTIVE, id); } else { localStorage.removeItem(FEDL_USER_ACCOUNT_ACTIVE); } } catch (e) {} } function fedlListAccounts(){ return read(FEDL_USER_ACCOUNTS, []); } function fedlSaveAccountsList(accounts){ write(FEDL_USER_ACCOUNTS, accounts); } function fedlNewAccountId(){ return `u_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; } function fedlEmptyRouletteSlots(){ return { '1': null, '2': null, '3': null }; } function fedlDefaultUserData(){ return { roulettePick: null, levelPercents: {}, savedRuns: [], rouletteSlots: fedlEmptyRouletteSlots(), theme: 'dark' }; } function fedlGetAccountPayload(accountId){ const raw = read(`fedl_user_data_${accountId}`, fedlDefaultUserData()); if (!Array.isArray(raw.savedRuns)) { raw.savedRuns = []; } if (!raw.levelPercents || typeof raw.levelPercents !== 'object') { raw.levelPercents = {}; } if (!raw.rouletteSlots || typeof raw.rouletteSlots !== 'object') { raw.rouletteSlots = fedlEmptyRouletteSlots(); } ['1', '2', '3'].forEach(k => { if (!Object.prototype.hasOwnProperty.call(raw.rouletteSlots, k)) { raw.rouletteSlots[k] = null; } }); return raw; } function fedlNextPercentHint(inputValue){ const raw = String(inputValue || '').trim().replace(',', '.'); if (!raw) { return { kind: 'muted', text: 'Enter your current best %, then tap Submit % to save and see the next % to aim for (+1% roulette step).' }; } const n = parseFloat(raw); if (!Number.isFinite(n) || n < 0 || n > 100) { return { kind: 'error', text: 'Enter a number from 0 to 100.' }; } if (n >= 100) { return { kind: 'success', text: 'You are at 100%. Beat the level, then spin — your next demon usually adds +1% to your roulette target.' }; } const next = Math.min(100, Math.floor(n) + 1); if (next >= 100) { return { kind: 'success', text: 'Saved. Next goal on this level: 100% (full completion).' }; } return { kind: 'success', text: `Saved. Next % to hit on this level: ${next}% (classic +1% roulette step).` }; } async function fedlReadJsonResponse(r){ const text = await r.text(); let data = {}; try { data = text ? JSON.parse(text) : {}; } catch (e) { data = {}; } const msg = (data && data.error && String(data.error)) || (data && data.message && String(data.message)) || ''; const plain = String(text || '').trim(); return { data, message: msg || plain || r.statusText || `Error ${r.status}` }; } function fedlAddSavedRun(accountId, fields){ if (!accountId || accountId !== fedlServerUserId) { return { ok: false, error: 'Sign in to save runs to your account.' }; } const playerName = String(fields.playerName || '').trim(); const levelTitle = String(fields.levelTitle || '').trim(); if (!playerName || !levelTitle) { return { ok: false, error: 'Player name and level are required to save a run.' }; } const p = fedlGetAccountPayload(accountId); const list = p.savedRuns.slice(); const id = `sv_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; list.unshift({ id, playerName, levelTitle, videoUrl: String(fields.videoUrl || '').trim(), percent: String(fields.percent != null ? fields.percent : '100').trim() || '100', rawFootageUrl: String(fields.rawFootageUrl || '').trim(), notes: String(fields.notes || '').trim(), savedAt: new Date().toISOString() }); p.savedRuns = list.slice(0, 48); fedlSaveAccountPayload(accountId, p); return { ok: true }; } function fedlRemoveSavedRun(accountId, runId){ if (!accountId || accountId !== fedlServerUserId || !runId) { return; } const p = fedlGetAccountPayload(accountId); p.savedRuns = (p.savedRuns || []).filter(r => r && r.id !== runId); fedlSaveAccountPayload(accountId, p); } function fedlSaveAccountPayload(accountId, payload){ write(`fedl_user_data_${accountId}`, payload); fedlSchedulePushUserState(accountId); } let fedlServerUserId = null; let fedlServerUsername = null; const FEDL_AUTH_TOKEN_KEY = 'fedl_auth_token'; function fedlGetAuthToken(){ try { return localStorage.getItem(FEDL_AUTH_TOKEN_KEY) || ''; } catch (e) { return ''; } } function fedlSetAuthToken(token){ try { if (token) { localStorage.setItem(FEDL_AUTH_TOKEN_KEY, token); } else { localStorage.removeItem(FEDL_AUTH_TOKEN_KEY); } } catch (e) {} } function fedlClearServerSession(){ fedlServerUserId = null; fedlServerUsername = null; fedlSetAuthToken(''); } function fedlDataUserId(){ if (fedlServerUserId) { return fedlServerUserId; } return fedlAccountId(); } let fedlPushStateTimer = null; function fedlSchedulePushUserState(accountId){ if (!accountId || !fedlGetAuthToken() || accountId !== fedlServerUserId || !canUseLiveServer) { return; } if (fedlPushStateTimer) { clearTimeout(fedlPushStateTimer); } fedlPushStateTimer = setTimeout(()=>{ const payload = fedlGetAccountPayload(accountId); fetch(`${liveServerBase}/api/user/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${fedlGetAuthToken()}` }, body: JSON.stringify({ data: payload }) }).catch(()=>{}); }, 450); } function fedlRefreshAuthState(){ const t = fedlGetAuthToken(); if (!t || !canUseLiveServer) { fedlServerUserId = null; fedlServerUsername = null; return Promise.resolve(null); } return fetch(`${liveServerBase}/api/auth/me`, { headers: { Authorization: `Bearer ${t}` }, cache: 'no-store' }).then(r=>{ if (!r.ok) { throw new Error('auth'); } return r.json(); }).then(j=>{ fedlServerUserId = j.userId; fedlServerUsername = j.username; return j; }).catch(()=>{ fedlClearServerSession(); return null; }); } function fedlPullUserStateToLocal(userId){ const t = fedlGetAuthToken(); if (!t || !userId || !canUseLiveServer) { return Promise.resolve(); } return fetch(`${liveServerBase}/api/user/state`, { headers: { Authorization: `Bearer ${t}` }, cache: 'no-store' }).then(r=>{ if (!r.ok) { return null; } return r.json(); }).then(j=>{ if (j && j.data) { write(`fedl_user_data_${userId}`, j.data); } }).catch(()=>{}); } function injectFedlAuthNav(){ const nav = document.querySelector('header nav'); if (!nav || nav.querySelector('.fedl-auth-nav')) { return; } const wrap = document.createElement('span'); wrap.className = 'fedl-auth-nav'; nav.appendChild(wrap); } function isFedlMod(){ if(!fedlServerUsername) return Promise.resolve(false); return Promise.resolve(MOD_USERS.includes(fedlServerUsername.toLowerCase())); } function fedlUpdateAuthNav(){ const wrap = document.querySelector('.fedl-auth-nav'); if (!wrap) { return; } wrap.textContent = ''; if (fedlServerUsername) { isFedlMod().then(isMod=>{ const label = document.createElement('span'); label.className = 'fedl-auth-label muted'; label.appendChild(document.createTextNode('Hi, ')); const strong = document.createElement('strong'); strong.textContent = fedlServerUsername; label.appendChild(strong); wrap.appendChild(label); wrap.appendChild(document.createTextNode(' ')); if (isMod) { const adminLink = document.createElement('a'); adminLink.href = 'admelist.html'; adminLink.textContent = 'Admin'; wrap.appendChild(adminLink); wrap.appendChild(document.createTextNode(' ')); } const accountLink = document.createElement('a'); accountLink.href = 'account.html'; accountLink.textContent = 'Account'; wrap.appendChild(accountLink); wrap.appendChild(document.createTextNode(' ')); const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn ghost-btn small-btn fedl-logout-btn'; btn.textContent = 'Log out'; btn.addEventListener('click', ()=>{ const tok = fedlGetAuthToken(); if (tok && canUseLiveServer) { fetch(`${liveServerBase}/api/auth/logout`, { method: 'POST', headers: { Authorization: `Bearer ${tok}` } }).catch(()=>{}); } fedlClearServerSession(); fedlUpdateAuthNav(); document.dispatchEvent(new CustomEvent('fedl-auth-updated')); window.location.reload(); }); wrap.appendChild(btn); }); } else { const a1 = document.createElement('a'); const returnTo = encodeURIComponent(window.location.href); a1.href = 'login.html?return=' + returnTo; a1.textContent = 'Log in'; wrap.appendChild(a1); wrap.appendChild(document.createTextNode(' ')); const a2 = document.createElement('a'); a2.href = 'signup.html'; a2.textContent = 'Sign up'; wrap.appendChild(a2); } } function fedlNormalizeLevelKey(title){ return String(title || '').trim().toLowerCase(); } function fedlGetLevelPercent(accountId, title){ if (!accountId) { return ''; } const p = fedlGetAccountPayload(accountId); const k = fedlNormalizeLevelKey(title); return (p.levelPercents && p.levelPercents[k]) ? String(p.levelPercents[k]) : ''; } function fedlSetLevelPercent(accountId, title, percent){ if (!accountId) { return; } const p = fedlGetAccountPayload(accountId); if (!p.levelPercents) { p.levelPercents = {}; } const k = fedlNormalizeLevelKey(title); const v = String(percent || '').trim(); if (v) { p.levelPercents[k] = v; } else { delete p.levelPercents[k]; } if (p.roulettePick && fedlNormalizeLevelKey(p.roulettePick.title) === k) { p.roulettePick.percent = v; } fedlSaveAccountPayload(accountId, p); } function fedlSaveRoulettePick(accountId, pick){ if (!accountId || !pick) { return; } const p = fedlGetAccountPayload(accountId); p.roulettePick = { title: pick.title, position: pick.position, level: pick.level, url: pick.url, levelId: pick.levelId, noteSource: pick.noteSource, percent: String(pick.percent || '').trim() }; if (p.roulettePick.title && p.roulettePick.percent) { if (!p.levelPercents) { p.levelPercents = {}; } p.levelPercents[fedlNormalizeLevelKey(p.roulettePick.title)] = p.roulettePick.percent; } fedlSaveAccountPayload(accountId, p); } function fedlCreateAccount(displayName){ const name = String(displayName || '').trim(); if (!name) { return null; } const accounts = fedlListAccounts(); const id = fedlNewAccountId(); accounts.push({ id, name, createdAt: new Date().toISOString() }); fedlSaveAccountsList(accounts); fedlSetActiveAccountId(id); fedlSaveAccountPayload(id, fedlDefaultUserData()); return { id, name }; } 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 updateAccountProgressInModal(modal, item){ if (!modal) { return; } const inner = modal.querySelector('.inner'); if (!inner) { return; } let accBar = modal.querySelector('.modal-account-progress'); if (!accBar) { accBar = document.createElement('div'); accBar.className = 'modal-account-progress'; const runsWrap = inner.querySelector('.modal-runs-wrap'); if (runsWrap) { inner.insertBefore(accBar, runsWrap); } else { inner.appendChild(accBar); } } const accId = fedlDataUserId(); if (!accId || !item || !item.title) { accBar.hidden = true; accBar.innerHTML = ''; return; } accBar.hidden = false; const cur = fedlGetLevelPercent(accId, item.title); const labelText = fedlServerUserId ? 'Your progress (synced to your account)' : 'Your progress (saved on this device)'; accBar.innerHTML = '' + ''; const input = accBar.querySelector('.modal-account-pct-input'); if (input) { input.value = cur; input.addEventListener('change', ()=>{ fedlSetLevelPercent(accId, item.title, input.value); }); } } function calculatePoints(rank){ if(!rank || rank < 1) return 0; if(rank <= 200) return 10000 - (rank - 1) * 50; return 50; } 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; } const rank = Number(item.position) || 0; const points = calculatePoints(rank); 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}`; let pointsEl = modal.querySelector('.modal-points'); if(!pointsEl){ pointsEl = document.createElement('div'); pointsEl.className='modal-points'; pointsEl.style.cssText = 'font-size:1.5em;font-weight:bold;margin-bottom:10px;color:var(--accent-color,#00aaff);'; const inner = modal.querySelector('.inner'); inner.insertBefore(pointsEl, inner.querySelector('iframe')); } pointsEl.textContent = rank > 0 ? `Rank #${rank} — ${points} points` : ''; 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); } updateAccountProgressInModal(modal, item); modal.style.display = 'flex'; } function bindHomeSnapshot(includeRuns){ const totalEl = qs('hero-total-levels'); const topEl = qs('hero-top-entry'); const approvedRunsEl = qs('hero-last-slot'); const playersEl = qs('hero-total-players'); const recentRunsEl = qs('hero-recent-runs'); const lastUpdatedEl = qs('hero-list-updated'); const featuredListEl = qs('featured-list'); const listPreviewEl = qs('offline-list-area'); 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, 10); if(!featured.length){ featuredListEl.innerHTML = ` `; return; } featuredListEl.innerHTML = featured.map(item=>` `).join(''); document.querySelectorAll('.skeleton-card').forEach(el => el.remove()); } 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); if(listPreviewEl){ const preview = rankedItems.slice(0, 10); listPreviewEl.innerHTML = preview.length ? preview.map(item=>` #${escapeHtml(item.position || '--')} ${escapeHtml(item.title || 'Untitled')} `).join('') : 'No local list data found.'; } } function renderHomeStats(runs, items){ const lookup = new Map((items || []).map(item => [String(item.title || '').toLowerCase(), Number(item.position) || 9999])); const map = new Map(); runs.forEach(run=>{ if(String(run.status || '').toLowerCase() !== 'approved') return; 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, points: 0}; 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(rank > 0 && rank < 1000) entry.points += calculatePoints(rank); }); const sortedPlayers = Array.from(map.values()).sort((a,b)=>{ if(b.points !== a.points) return b.points - a.points; const aRank = a.bestRank === 9999 ? 9999 : a.bestRank; const bRank = b.bestRank === 9999 ? 9999 : b.bestRank; if(aRank !== bRank) return aRank - bRank; return a.name.localeCompare(b.name); }); const topPlayer = sortedPlayers[0]?.name || 'None'; if(playersEl) playersEl.textContent = topPlayer; const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; const recentRuns = runs.filter(run=>{ if(String(run.status || '').toLowerCase() !== 'approved') return false; const submitted = run.submittedAt ? new Date(run.submittedAt).getTime() : 0; return submitted >= oneWeekAgo; }).length; if(recentRunsEl) recentRunsEl.textContent = String(recentRuns); const sortedRuns = runs.slice().sort((a,b)=>new Date(b.submittedAt||0) - new Date(a.submittedAt||0)); const latestRun = sortedRuns.find(run=>run.submittedAt); if(lastUpdatedEl) lastUpdatedEl.textContent = latestRun ? new Date(latestRun.submittedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'N/A'; } function renderApprovedRuns(runs){ if(!approvedRunsEl) return; const approvedCount = runs.filter(run=>String(run.status || '').toLowerCase() === 'approved').length; approvedRunsEl.textContent = String(approvedCount); } loadItems().then(items=>{ renderHome(items); if(includeRuns){ loadRuns().then(runs=>{ renderApprovedRuns(runs); renderHomeStats(runs, items); }).catch(()=>{ renderApprovedRuns([]); renderHomeStats([], items); }); onRunsUpdate(runs=>{ renderApprovedRuns(runs); renderHomeStats(runs, items); }); } }).catch(()=>{ renderHome([]); }); onLiveUpdate(renderHome); } if(page==='index'){ bindHomeSnapshot(true); bindLiveUpdates(); } // One page app handler if(page===SPA_PAGE_KEY){ const spaPage = window.location.hash.slice(1) || 'home'; if(spaPage === 'lists' || spaPage === 'home'){ bindHomeSnapshot(true); bindLiveUpdates(); const listBody = qs('list-body'); if(listBody){ const listPage = initListPage(); onLiveUpdate(function(updatedItems){ listPage.applyItems(updatedItems); }); } } if(spaPage === 'players'){ const playersBody = qs('players-body'); const playerSearch = qs('player-search'); if(playersBody && playerSearch){ let players = []; Promise.all([loadRuns(), loadItems()]).then(([runs, items])=>{ const lookup = new Map(items.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, points: 0, 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(rank > 0 && rank < 1000) entry.points += calculatePoints(rank); if(run.levelTitle) entry.topLevels.add(String(run.levelTitle).trim()); }); players = Array.from(map.values()).map(entry => ({ name: entry.name, runs: entry.runs, bestRank: entry.bestRank === 9999 ? '—' : `#${entry.bestRank}`, points: entry.points, topLevels: Array.from(entry.topLevels).slice(0, 3).join(', ') })).sort((a,b) => b.points - a.points || a.name.localeCompare(b.name)); function render(){ const query = (playerSearch.value || '').toLowerCase(); const filtered = players.filter(p => !query || p.name.toLowerCase().includes(query)); if(!filtered.length){ playersBody.innerHTML = 'No player data found.'; return; } playersBody.innerHTML = filtered.slice(0, 100).map(p => ` ${escapeHtml(p.name)} ${p.runs} ${p.bestRank} ${p.points} ${escapeHtml(p.topLevels)} `).join(''); } playerSearch.addEventListener('input', render); render(); }); } } if(spaPage === 'run'){ const runForm = qs('run-form'); if(runForm){ const playerNameInput = qs('run-player-name'); const formStatusEl = qs('run-form-status'); runForm.addEventListener('submit', function(e){ e.preventDefault(); const playerName = playerNameInput.value.trim(); const levelTitle = qs('run-level-title').value.trim(); const videoUrl = qs('run-video-url').value.trim(); const percent = qs('run-percent').value.trim(); const rawUrl = qs('run-raw-url').value.trim(); const notes = qs('run-notes').value.trim(); if(!playerName || !levelTitle || !videoUrl || !percent){ formStatusEl.textContent = 'Please fill in all required fields.'; return; } formStatusEl.textContent = 'Submitting...'; fetch(liveRunsUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playerName, levelTitle, videoUrl, percent, rawFootageUrl: rawUrl, notes }) }).then(r => { if(!r.ok) throw new Error('Failed'); formStatusEl.textContent = 'Run submitted successfully!'; runForm.reset(); }).catch(() => { formStatusEl.textContent = 'Failed to submit run.'; }); }); } } if(spaPage === 'roulette'){ const spinBtn = qs('roulette-spin'); const resultEl = qs('roulette-result'); if(spinBtn && resultEl){ spinBtn.addEventListener('click', function(){ loadItems().then(items => { if(!items.length){ resultEl.innerHTML = '

No levels loaded.

'; return; } const random = items[Math.floor(Math.random() * items.length)]; resultEl.innerHTML = `

${escapeHtml(random.title || 'Untitled')}

Rank #${random.position || '?'}

${random.level || ''}

`; }); }); } } if(spaPage === 'guess'){ const higherBtn = qs('guess-higher'); const lowerBtn = qs('guess-lower'); const levelEl = qs('guess-level'); const resultEl = qs('guess-result'); const scoreEl = qs('guess-score'); if(higherBtn && lowerBtn && levelEl){ let currentLevel = null; let score = 0; let revealed = false; function newRound(){ loadItems().then(items => { if(!items.length) return; currentLevel = items[Math.floor(Math.random() * items.length)]; levelEl.textContent = currentLevel.title || '???'; resultEl.textContent = ''; revealed = false; }); } higherBtn.addEventListener('click', function(){ if(revealed || !currentLevel) return; revealed = true; const rank = Number(currentLevel.position) || 9999; const actual = Math.random() * 100; if(actual > 50){ score++; resultEl.textContent = `Correct! It was #${rank}`; }else{ resultEl.textContent = `Wrong! It was #${rank}`; } scoreEl.textContent = `Score: ${score}`; setTimeout(newRound, 2000); }); lowerBtn.addEventListener('click', function(){ if(revealed || !currentLevel) return; revealed = true; const rank = Number(currentLevel.position) || 9999; const actual = Math.random() * 100; if(actual < 50){ score++; resultEl.textContent = `Correct! It was #${rank}`; }else{ resultEl.textContent = `Wrong! It was #${rank}`; } scoreEl.textContent = `Score: ${score}`; setTimeout(newRound, 2000); }); newRound(); } } } if(page==='offlineindex'){ bindHomeSnapshot(false); } 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'); const pctInput = qs('roulette-percent'); const pctRow = qs('roulette-progress-row'); const accountSelect = qs('roulette-account-select'); const accountNewInput = qs('roulette-account-new'); const accountCreateBtn = qs('roulette-account-create'); const restoreBtn = qs('roulette-restore'); const pctHint = qs('roulette-percent-hint'); const loginSyncHint = qs('roulette-login-sync-hint'); const slotsHintEl = qs('roulette-slots-hint'); const pctSubmitBtn = qs('roulette-percent-submit'); let lastRoulette = { item: null, meta: null }; function setPercentHint(text, kind){ if(!pctHint) return; pctHint.textContent = text || ''; pctHint.className = 'small roulette-percent-hint ' + (kind === 'error' ? 'error-text' : kind === 'success' ? 'success-text' : 'muted'); } function resetPercentHint(){ const h = fedlNextPercentHint(''); setPercentHint(h.text, h.kind); } function refreshRouletteSlotsUi(){ const aid = fedlDataUserId(); ['1', '2', '3'].forEach(k=>{ const saveB = qs(`roulette-slot-save-${k}`); const loadB = qs(`roulette-slot-load-${k}`); const lab = qs(`roulette-slot-label-${k}`); if(saveB) saveB.disabled = !aid; if(loadB) loadB.disabled = !aid; if(lab){ if(!aid){ lab.textContent = '—'; }else{ const slot = fedlGetAccountPayload(aid).rouletteSlots[k]; if(slot && slot.title){ const pct = slot.percent ? ` @ ${slot.percent}%` : ''; const t = String(slot.title); const short = t.length > 36 ? `${t.slice(0, 34)}…` : t; lab.textContent = short + pct; }else{ lab.textContent = 'Empty'; } } } }); if(slotsHintEl){ if(!aid){ slotsHintEl.textContent = 'Create a profile below or log in to use save slots.'; }else{ slotsHintEl.textContent = 'Save the demon on screen into a slot, or load a slot to swap demons.'; } } } function syncPercentRow(){ if(!pctRow) return; const aid = fedlDataUserId(); if(!aid || !lastRoulette.item){ pctRow.hidden = true; if(pctInput) pctInput.value = ''; if(pctHint) pctHint.textContent = ''; return; } pctRow.hidden = false; if(pctInput){ pctInput.value = fedlGetLevelPercent(aid, lastRoulette.item.title) || ''; } resetPercentHint(); } function refreshRouletteAccountUi(){ const panel = document.querySelector('.roulette-account-panel'); const serverMode = !!fedlServerUsername; if(loginSyncHint){ loginSyncHint.hidden = !!fedlServerUserId; } if(panel){ const controls = panel.querySelector('.roulette-account-controls'); const createRow = panel.querySelector('.roulette-account-create-row'); const selLabel = panel.querySelector('.roulette-account-label'); let note = panel.querySelector('.fedl-server-account-note'); if(serverMode){ if(controls) controls.style.display = 'none'; if(createRow) createRow.style.display = 'none'; if(selLabel) selLabel.style.display = 'none'; if(!note){ note = document.createElement('p'); note.className = 'muted fedl-server-account-note'; const heading = panel.querySelector('.roulette-account-heading'); if(heading){ heading.insertAdjacentElement('afterend', note); }else{ panel.appendChild(note); } } note.textContent = `Signed in as ${fedlServerUsername}. Progress syncs online and this browser keeps a copy.`; note.style.display = ''; }else{ if(controls) controls.style.display = ''; if(createRow) createRow.style.display = ''; if(selLabel) selLabel.style.display = ''; if(note) note.style.display = 'none'; } } if(!accountSelect || serverMode){ if(restoreBtn){ const id = fedlDataUserId(); const pick = id ? fedlGetAccountPayload(id).roulettePick : null; restoreBtn.hidden = !pick || !pick.title; } refreshRouletteSlotsUi(); syncPercentRow(); return; } const accounts = fedlListAccounts(); const active = fedlAccountId(); accountSelect.innerHTML = ''; accounts.forEach(a=>{ const opt = document.createElement('option'); opt.value = a.id; opt.textContent = a.name; if(a.id === active) opt.selected = true; accountSelect.appendChild(opt); }); if(restoreBtn){ const pick = active ? fedlGetAccountPayload(active).roulettePick : null; restoreBtn.hidden = !pick || !pick.title; } refreshRouletteSlotsUi(); syncPercentRow(); } function showPick(item, meta){ lastRoulette = { 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; } const aid = fedlDataUserId(); if(aid){ const pct = fedlGetLevelPercent(aid, item.title) || ''; fedlSaveRoulettePick(aid, { title: item.title, position: item.position, level: item.level, url: item.url, levelId: meta.levelId, noteSource: meta.source, percent: pct }); refreshRouletteAccountUi(); } syncPercentRow(); } function saveRouletteSlot(slotKey){ const aid = fedlDataUserId(); if(!aid || !lastRoulette.item){ if(slotsHintEl) slotsHintEl.textContent = 'Spin a demon first, and use a profile or log in.'; return; } const p = fedlGetAccountPayload(aid); const pct = pctInput ? String(pctInput.value || '').trim() : ''; p.rouletteSlots[slotKey] = { title: lastRoulette.item.title, position: lastRoulette.item.position, level: lastRoulette.item.level, url: lastRoulette.item.url, levelId: lastRoulette.meta && lastRoulette.meta.levelId, noteSource: lastRoulette.meta && lastRoulette.meta.source, percent: pct, savedAt: new Date().toISOString() }; fedlSaveAccountPayload(aid, p); if(pct){ fedlSetLevelPercent(aid, lastRoulette.item.title, pct); } refreshRouletteSlotsUi(); } function loadRouletteSlot(slotKey){ const aid = fedlDataUserId(); if(!aid) return; const slot = fedlGetAccountPayload(aid).rouletteSlots[slotKey]; if(!slot || !slot.title){ if(slotsHintEl) slotsHintEl.textContent = 'That slot is empty.'; return; } const pctStr = String(slot.percent != null ? slot.percent : '').trim(); if(pctStr){ fedlSetLevelPercent(aid, slot.title, pctStr); } const item = { title: slot.title, position: slot.position, level: slot.level, url: slot.url }; const meta = { levelId: slot.levelId, source: slot.noteSource === 'api' ? 'api' : 'file' }; showPick(item, meta); if(pctInput){ pctInput.value = pctStr || fedlGetLevelPercent(aid, slot.title) || ''; } resetPercentHint(); refreshRouletteSlotsUi(); } if(pctInput){ pctInput.addEventListener('change', ()=>{ const aid = fedlDataUserId(); if(!aid || !lastRoulette.item) return; fedlSetLevelPercent(aid, lastRoulette.item.title, pctInput.value); }); } if(pctSubmitBtn){ pctSubmitBtn.addEventListener('click', ()=>{ const aid = fedlDataUserId(); if(!aid || !lastRoulette.item){ setPercentHint('Spin a demon and use a profile or log in to track %.', 'error'); return; } fedlSetLevelPercent(aid, lastRoulette.item.title, pctInput ? pctInput.value : ''); const h = fedlNextPercentHint(pctInput ? pctInput.value : ''); setPercentHint(h.text, h.kind); }); } ['1', '2', '3'].forEach(k=>{ const sb = qs(`roulette-slot-save-${k}`); const lb = qs(`roulette-slot-load-${k}`); if(sb) sb.addEventListener('click', ()=> saveRouletteSlot(k)); if(lb) lb.addEventListener('click', ()=> loadRouletteSlot(k)); }); if(accountSelect){ accountSelect.addEventListener('change', ()=>{ fedlSetActiveAccountId(accountSelect.value || ''); refreshRouletteAccountUi(); }); } if(accountCreateBtn && accountNewInput){ accountCreateBtn.addEventListener('click', ()=>{ const name = String(accountNewInput.value || '').trim(); if(!name) return; fedlCreateAccount(name); accountNewInput.value = ''; refreshRouletteAccountUi(); }); } if(restoreBtn){ restoreBtn.addEventListener('click', ()=>{ const aid = fedlDataUserId(); if(!aid) return; const pick = fedlGetAccountPayload(aid).roulettePick; if(!pick || !pick.title) return; const item = { title: pick.title, position: pick.position, level: pick.level, url: pick.url }; const meta = { levelId: pick.levelId, source: pick.noteSource === 'api' ? 'api' : 'file' }; showPick(item, meta); }); } document.addEventListener('fedl-auth-updated', ()=>{ refreshRouletteAccountUi(); }); fedlRefreshAuthState() .then(()=> fedlPullUserStateToLocal(fedlServerUserId)) .finally(()=>{ refreshRouletteAccountUi(); fedlUpdateAuthNav(); }); 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; if(pctRow) pctRow.hidden = true; if(pctHint) pctHint.textContent = ''; Promise.all([loadItems(), loadLevelMeta()]).then(([items, metaMap])=>{ if(!items.length){ statusEl.textContent = 'No demons found.'; titleEl.textContent = 'Add demons to the 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 = 'Open the full site'; rankEl.textContent = 'Rank: -'; idEl.textContent = 'Level ID: -'; noteEl.textContent = 'The list or 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, points: 0, 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(rank > 0 && rank < 1000) entry.points += calculatePoints(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}`, points: entry.points, topLevels: Array.from(entry.topLevels).slice(0, 3).join(', ') })).sort((a,b) => { if(b.points !== a.points) return b.points - a.points; 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 player data found.'; return; } const fragment = document.createDocumentFragment(); 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 tdPoints = document.createElement('td'); tdPoints.textContent = String(item.points); 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(tdPoints); tr.appendChild(tdRank); tr.appendChild(tdLevels); fragment.appendChild(tr); }); playersArea.appendChild(fragment); } function showLoading(){ playersArea.innerHTML = 'Loading player stats...'; } 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 player stats.'; }); } searchEl.addEventListener('input', debounce(renderTable, 120)); 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(){ const run = ()=>{ loadItems().then(items=>{ applyItems(items); }).catch(err=>{listArea.innerHTML='

Failed to load list data.

'; console.error(err)}); }; fedlRefreshAuthState() .then(()=> fedlPullUserStateToLocal(fedlServerUserId)) .finally(run); } 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', debounce(()=> renderTable(currentItems), 120)); 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=''; if(!filtered.length){ tbody.innerHTML = 'No levels match this search or range.'; return; } const accId = fedlDataUserId(); const fragment = document.createDocumentFragment(); 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 tdPct = document.createElement('td'); tdPct.className = 'list-my-progress-cell'; if(accId){ const inp = document.createElement('input'); inp.type = 'text'; inp.className = 'list-progress-input'; inp.value = fedlGetLevelPercent(accId, it.title) || ''; inp.placeholder = '%'; inp.title = 'Your progress for this level (this browser)'; inp.addEventListener('change', ()=>{ fedlSetLevelPercent(accId, it.title, inp.value); }); tdPct.appendChild(inp); }else{ const span = document.createElement('span'); span.className = 'muted'; span.textContent = '—'; span.title = 'Create a profile on the Roulette page to save progress'; tdPct.appendChild(span); } 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(tdPct); tr.appendChild(tdAct); fragment.appendChild(tr); }); tbody.appendChild(fragment); } 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(); document.addEventListener('fedl-auth-updated', ()=>{ loadItems().then(items=>listPage.applyItems(items)).catch(()=>{}); }); bindLiveUpdates(); onLiveUpdate(function(updatedItems){ listPage.applyItems(updatedItems); }); } if(page==='admelist'){ fedlRefreshAuthState().then(j=>{ if(!j) return; const username = (j.username || '').toLowerCase(); if(!username || !MOD_USERS.map(m=>m.toLowerCase()).includes(username)){ window.location.href = 'index.html'; } }); const loginScreenEl = qs('admin-login-screen'); const adminShellContentEl = qs('admin-shell-content'); const loginFormEl = qs('admin-login-form'); const adminPasswordEl = qs('admin-password'); const authStatusEl = qs('admin-auth-status'); const statusEl = qs('admin-status'); const listTbody = qs('admin-list-body'); const addBtn = qs('add-row'); const addRowBottomBtn = qs('add-row-bottom'); const saveBtn = qs('save-list'); const searchEl = qs('admin-search'); 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'); const importTargetedOpenBtn = qs('import-targeted-open'); const importTargetedModal = qs('import-targeted-modal'); const importTargetedForm = qs('import-targeted-form'); const importTargetedSourceEl = qs('import-targeted-source'); const importTargetedQueryEl = qs('import-targeted-query'); const importTargetedQueryLabelEl = qs('import-targeted-query-label'); const importTargetedSubmitBtn = qs('import-targeted-submit'); const importTargetedCancelBtn = qs('import-targeted-cancel'); const bulkApproveOpenBtn = qs('bulk-approve-open'); const bulkApproveModal = qs('bulk-approve-modal'); const bulkApproveForm = qs('bulk-approve-form'); const bulkApprovePlayerInput = qs('bulk-approve-player'); const bulkApproveNotesInput = qs('bulk-approve-notes'); const bulkApprovePreviewEl = qs('bulk-approve-preview'); const bulkApproveCancelBtn = qs('bulk-approve-cancel'); const bulkApproveSubmitBtn = qs('bulk-approve-submit'); 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){ setAdminPassword(''); document.body.classList.add('admin-locked'); if(adminShellContentEl) adminShellContentEl.hidden = true; if(loginScreenEl) loginScreenEl.hidden = false; if(authStatusEl){ authStatusEl.textContent = message || 'Admin password required or incorrect.'; authStatusEl.classList.add('error-text'); } } 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.'); 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 && r.status !== 204) throw new Error('Verify failed'); return true; }).then(ok=>{ unlockAdminShell(); loadAdmin(); loadRunsAdmin(); return ok; }).catch(err=>{ console.error(err); handleAdminAuthFailure('Wrong admin password. Try again.'); return false; }); } 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(); }); } if(getAdminPassword()){ verifyAdminPassword(); } 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.'); updateStats(); }).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.'); updateStats(); }).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: fedlServerUsername || '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){ 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.'); }); if(addRowBottomBtn){ addRowBottomBtn.addEventListener('click', function(){ items.push({level:'new', position:'', title:'', url:'', _isDraft:true}); normalizePositions(); renderAdminTable(); setStatus('New row added at the bottom.'); }); } 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, importTargetedOpenBtn, bulkApproveOpenBtn].forEach(btn=>{ if(btn) btn.disabled = !enabled; }); } function updateImportTargetedQueryLabel(){ if(!importTargetedQueryLabelEl) return; const levelRadio = document.querySelector('input[name="import-targeted-filter"][value="level"]'); const isLevel = levelRadio && levelRadio.checked; importTargetedQueryLabelEl.textContent = isLevel ? 'Level name or id' : 'Player name'; if(importTargetedQueryEl){ importTargetedQueryEl.placeholder = isLevel ? 'e.g. Acheron or demon id' : 'Name as on the list'; } } function openImportTargetedModal(){ if(!importTargetedModal) return; importTargetedModal.hidden = false; if(importTargetedForm) importTargetedForm.reset(); const playerRadio = document.querySelector('input[name="import-targeted-filter"][value="player"]'); if(playerRadio) playerRadio.checked = true; updateImportTargetedQueryLabel(); if(importTargetedQueryEl) importTargetedQueryEl.focus(); } function closeImportTargetedModal(){ if(importTargetedModal) importTargetedModal.hidden = true; } function submitImportTargeted(event){ event.preventDefault(); if(!canUseLiveServer){ setImportStatus('Start the Node server to use the import tool.', true); return; } const source = importTargetedSourceEl && String(importTargetedSourceEl.value || '').trim(); const filterRadio = document.querySelector('input[name="import-targeted-filter"]:checked'); const filter = filterRadio && String(filterRadio.value || '').trim(); const query = importTargetedQueryEl && String(importTargetedQueryEl.value || '').trim(); if(!source || !filter || !query) return; if(importTargetedSubmitBtn) importTargetedSubmitBtn.disabled = true; setImportStatus(`Fetching ${source} records…`); toggleImportButtons(false); fetch(liveApiPath('/api/import/targeted'), { method: 'POST', headers: authHeaders({'Content-Type':'application/json'}), body: JSON.stringify({ source, filter, query }) }).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 (${response.status})`); return payload; }).then(payload=>{ clearRunsCache(); const matched = Number(payload.matched) || 0; const added = Number(payload.added) || 0; const skipped = Number(payload.skipped) || 0; setImportStatus( `Targeted import: ${matched} API record${matched === 1 ? '' : 's'} matched, ${added} added, ${skipped} skipped (duplicates or missing video). Notes set to “Valid run”.` ); closeImportTargetedModal(); return refreshRuns(); }).catch(err=>{ console.error(err); if(String(err && err.message || '') === 'Admin auth failed'){ handleAdminAuthFailure('Wrong admin password. Enter it above, then try again.', setImportStatus); closeImportTargetedModal(); return; } setImportStatus(err.message || 'Targeted import failed.', true); }).finally(()=>{ if(importTargetedSubmitBtn) importTargetedSubmitBtn.disabled = false; toggleImportButtons(true); }); } let bulkApprovePreviewTimer = null; function countPendingRunsForPlayer(name){ const q = String(name || '').trim().toLowerCase(); if(!q) return 0; return runs.filter(run=>{ const st = String(run.status || 'pending').toLowerCase(); const pn = String(run.playerName || '').trim().toLowerCase(); return st === 'pending' && pn === q; }).length; } function updateBulkApprovePreview(){ if(!bulkApprovePreviewEl || !bulkApprovePlayerInput) return; const n = countPendingRunsForPlayer(bulkApprovePlayerInput.value); const label = String(bulkApprovePlayerInput.value || '').trim(); if(!label){ bulkApprovePreviewEl.textContent = ''; return; } bulkApprovePreviewEl.textContent = n ? `${n} pending run${n === 1 ? '' : 's'} match this name in the current queue.` : 'No pending runs match this name in the current queue.'; } function openBulkApproveModal(){ if(!bulkApproveModal) return; bulkApproveModal.hidden = false; if(bulkApproveForm) bulkApproveForm.reset(); updateBulkApprovePreview(); if(bulkApprovePlayerInput){ bulkApprovePlayerInput.focus(); } } function closeBulkApproveModal(){ if(bulkApproveModal) bulkApproveModal.hidden = true; if(bulkApprovePreviewTimer){ clearTimeout(bulkApprovePreviewTimer); bulkApprovePreviewTimer = null; } } function submitBulkApprove(event){ event.preventDefault(); if(!canUseLiveServer){ setImportStatus('Start the Node server to use the import tool.', true); return; } const playerName = bulkApprovePlayerInput && String(bulkApprovePlayerInput.value || '').trim(); if(!playerName) return; const reviewNotesRaw = bulkApproveNotesInput && String(bulkApproveNotesInput.value || '').trim(); const body = { playerName }; if(reviewNotesRaw) body.reviewNotes = reviewNotesRaw; if(bulkApproveSubmitBtn) bulkApproveSubmitBtn.disabled = true; setImportStatus('Bulk-approving pending runs for that player...'); fetch(`${liveRunsUrl}/bulk-approve`, { method: 'POST', headers: authHeaders({'Content-Type':'application/json'}), body: JSON.stringify(body) }).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 || `Bulk approve failed (${response.status})`); return payload; }).then(payload=>{ clearRunsCache(); const n = Number(payload.approved) || 0; setImportStatus(n ? `Bulk approve done: ${n} pending run${n === 1 ? '' : 's'} approved for ${playerName}.` : `No pending runs to approve for ${playerName}.`); closeBulkApproveModal(); return refreshRuns(); }).catch(err=>{ console.error(err); if(String(err && err.message || '') === 'Admin auth failed'){ handleAdminAuthFailure('Wrong admin password. Enter it above, then try again.', setImportStatus); closeBulkApproveModal(); return; } setImportStatus(err.message || 'Bulk approve failed.', true); }).finally(()=>{ if(bulkApproveSubmitBtn) bulkApproveSubmitBtn.disabled = false; }); } function runImport(path, label){ setImportStatus(`Importing ${label} runs...`); toggleImportButtons(false); return fetch(liveApiPath(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(importTargetedOpenBtn){ importTargetedOpenBtn.addEventListener('click', ()=> openImportTargetedModal()); } if(importTargetedCancelBtn){ importTargetedCancelBtn.addEventListener('click', ()=> closeImportTargetedModal()); } if(importTargetedModal){ importTargetedModal.addEventListener('click', event=>{ if(event.target === importTargetedModal) closeImportTargetedModal(); }); } if(importTargetedForm){ importTargetedForm.addEventListener('submit', submitImportTargeted); importTargetedForm.querySelectorAll('input[name="import-targeted-filter"]').forEach(radio=>{ radio.addEventListener('change', updateImportTargetedQueryLabel); }); } if(bulkApproveOpenBtn){ bulkApproveOpenBtn.addEventListener('click', ()=> openBulkApproveModal()); } if(bulkApproveCancelBtn){ bulkApproveCancelBtn.addEventListener('click', ()=> closeBulkApproveModal()); } if(bulkApproveModal){ bulkApproveModal.addEventListener('click', event=>{ if(event.target === bulkApproveModal) closeBulkApproveModal(); }); } if(bulkApproveForm){ bulkApproveForm.addEventListener('submit', submitBulkApprove); } if(bulkApprovePlayerInput){ bulkApprovePlayerInput.addEventListener('input', ()=>{ if(bulkApprovePreviewTimer) clearTimeout(bulkApprovePreviewTimer); bulkApprovePreviewTimer = setTimeout(updateBulkApprovePreview, 200); }); } document.addEventListener('keydown', event=>{ if(event.key !== 'Escape') return; if(importTargetedModal && !importTargetedModal.hidden){ closeImportTargetedModal(); return; } if(bulkApproveModal && !bulkApproveModal.hidden) closeBulkApproveModal(); }); document.addEventListener('keydown', event=>{ if(event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') return; const key = event.key.toLowerCase(); if(key === '/' && !event.ctrlKey && !event.metaKey){ event.preventDefault(); const searchInput = document.querySelector('.search-row input[type="text"], #list-search, .levels-table input'); if(searchInput) searchInput.focus(); } if(key === 'h' && !event.ctrlKey && !event.metaKey){ window.location.href = 'index.html'; } if(key === 'l' && !event.ctrlKey && !event.metaKey){ window.location.href = 'lists.html'; } if(key === 'r' && !event.ctrlKey && !event.metaKey){ window.location.href = 'run.html'; } if(key === '?' && !event.ctrlKey && !event.metaKey){ window.location.href = 'rules.html'; } }); 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.'); }); loadAdmin(); loadRunsAdmin(); const bugReportsBody = qs('bug-reports-body'); const bugReportSearchEl = qs('bug-report-search'); const bugReportsStatusEl = qs('bug-reports-admin-status'); let bugReports = []; function setBugReportsStatus(message, isError){ if(!bugReportsStatusEl) return; bugReportsStatusEl.textContent = message; bugReportsStatusEl.classList.toggle('error-text', !!isError); } function loadBugReports(){ if(!canUseLiveServer){ setBugReportsStatus('Start the Node server to load bug reports.', true); return Promise.resolve([]); } return fetch(`${liveServerBase}/api/bugreports`, { method: 'GET', headers: authHeaders() }).then(r=>{ if(r.status === 401) throw new Error('Admin auth failed'); if(!r.ok) throw new Error('Failed to load bug reports'); return r.json(); }).then(payload=>{ return Array.isArray(payload.items) ? payload.items : []; }).catch(err=>{ console.error(err); setBugReportsStatus('Could not load bug reports.', true); return []; }); } function refreshBugReports(){ return loadBugReports().then(loaded=>{ bugReports = loaded.slice().sort((a,b)=>new Date(b.submittedAt) - new Date(a.submittedAt)); renderBugReportsTable(); updateStats(); }); } function filteredBugReports(){ const query = (bugReportSearchEl && bugReportSearchEl.value || '').trim().toLowerCase(); if(!query) return bugReports; return bugReports.filter(report=>{ return [ report.category, report.status, report.subject, report.description, report.accountUsername ].some(value=>String(value || '').toLowerCase().includes(query)); }); } function renderBugReportsTable(){ const rows = filteredBugReports(); if(!bugReportsBody) return; bugReportsBody.innerHTML = ''; rows.forEach(report=>{ const tr = document.createElement('tr'); tr.innerHTML = ` ${escapeHtml(report.category || 'other')} ${escapeHtml(report.status || 'open')} ${escapeHtml(report.accountUsername || 'Anonymous')}
${escapeHtml(report.subject || 'Untitled')} ${escapeHtml((report.description || '').slice(0, 100))}${report.description && report.description.length > 100 ? '...' : ''}
${escapeHtml(formatDate(report.submittedAt))}
`; bugReportsBody.appendChild(tr); const detailRow = document.createElement('tr'); detailRow.className = 'bug-report-detail-row'; detailRow.innerHTML = `
Email: ${report.email ? escapeHtml(report.email) : 'Not provided'} Description: ${escapeHtml(report.description || 'No description')}
`; bugReportsBody.appendChild(detailRow); }); if(!rows.length){ const tr = document.createElement('tr'); tr.innerHTML = 'No bug reports match your search.'; bugReportsBody.appendChild(tr); } } function updateBugReportStatus(reportId, status){ if(!canUseLiveServer){ setBugReportsStatus('Start the Node server to update bug reports.', true); return; } const report = bugReports.find(entry=>entry.id === reportId); if(!report) return; fetch(`${liveServerBase}/api/bugreports/${encodeURIComponent(reportId)}`, { method:'PUT', headers:authHeaders({'Content-Type':'application/json'}), body: JSON.stringify({ ...report, status }) }).then(r=>{ if(r.status === 401) throw new Error('Admin auth failed'); if(!r.ok) throw new Error('Bug report update failed'); return refreshBugReports(); }).then(()=>{ setBugReportsStatus(`Bug report marked ${status}.`); }).catch(err=>{ console.error(err); if(String(err && err.message || '') === 'Admin auth failed'){ handleAdminAuthFailure('Wrong admin password. Enter it above to update bug reports.', setBugReportsStatus); return; } setBugReportsStatus('Could not update that bug report.', true); }); } function deleteBugReport(reportId){ if(!canUseLiveServer){ setBugReportsStatus('Start the Node server to delete bug reports.', true); return; } fetch(`${liveServerBase}/api/bugreports/${encodeURIComponent(reportId)}`, { method:'DELETE', headers:authHeaders() }).then(r=>{ if(r.status === 401) throw new Error('Admin auth failed'); if(!r.ok) throw new Error('Bug report delete failed'); return refreshBugReports(); }).then(()=>{ setBugReportsStatus('Bug report removed.'); }).catch(err=>{ console.error(err); if(String(err && err.message || '') === 'Admin auth failed'){ handleAdminAuthFailure('Wrong admin password. Enter it above to delete bug reports.', setBugReportsStatus); return; } setBugReportsStatus('Could not delete that bug report.', true); }); } if(bugReportsBody){ bugReportsBody.addEventListener('click', function(event){ const actionButton = event.target.closest('[data-bug-action]'); if(actionButton){ updateBugReportStatus( actionButton.getAttribute('data-bug-id'), actionButton.getAttribute('data-bug-action') ); return; } const deleteButton = event.target.closest('[data-bug-delete]'); if(deleteButton){ const reportId = deleteButton.getAttribute('data-bug-delete'); if(reportId && window.confirm('Delete this bug report?')){ deleteBugReport(reportId); } } }); } if(bugReportSearchEl){ bugReportSearchEl.addEventListener('input', renderBugReportsTable); } function updateStats(){ const totalRuns = Array.isArray(runs) ? runs.length : 0; const pendingRuns = Array.isArray(runs) ? runs.filter(r=>String(r.status || '').toLowerCase() === 'pending').length : 0; const levelsCount = items ? items.filter(i=>i && i.title && !i._isDraft).length : 0; const openReports = Array.isArray(bugReports) ? bugReports.filter(r=>String(r.status || '').toLowerCase() === 'open').length : 0; const statTotalRuns = qs('stat-total-runs'); const statPendingRuns = qs('stat-pending-runs'); const statLevelsCount = qs('stat-levels-count'); const statBugReports = qs('stat-bug-reports'); const statsStatusEl = qs('admin-stats-status'); if(statTotalRuns) statTotalRuns.textContent = totalRuns; if(statPendingRuns) statPendingRuns.textContent = pendingRuns; if(statLevelsCount) statLevelsCount.textContent = levelsCount; if(statBugReports) statBugReports.textContent = openReports; if(statsStatusEl) statsStatusEl.textContent = 'Stats loaded.'; } if(bugReportsBody || qs('stat-total-runs')){ refreshBugReports().then(()=>{ setBugReportsStatus('Bug reports loaded.'); }); } 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.'); updateStats(); }); 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.'); updateStats(); }); const adminTabButtons = document.querySelectorAll('.admin-tab'); const adminTabPanels = document.querySelectorAll('.admin-tab-panel'); function switchAdminTab(tabName){ adminTabButtons.forEach(btn=>{ btn.classList.toggle('active', btn.getAttribute('data-tab') === tabName); }); adminTabPanels.forEach(panel=>{ panel.hidden = panel.id !== `tab-${tabName}`; }); } adminTabButtons.forEach(btn=>{ btn.addEventListener('click', ()=>{ const tab = btn.getAttribute('data-tab'); if(tab) switchAdminTab(tab); }); }); } 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'); const myRunsSection = qs('run-my-runs-section'); const savedRunsListEl = qs('run-saved-runs-list'); const saveAccountBtn = qs('run-save-account'); const playerNameInput = qs('run-player-name'); function setRunFormStatus(message, isError, isSuccess){ if(!formStatusEl) return; formStatusEl.textContent = message; formStatusEl.classList.toggle('error-text', !!isError); formStatusEl.classList.toggle('success-text', !!isSuccess); } function setRunListStatus(message, isError){ if(!listStatusEl) return; listStatusEl.textContent = message; listStatusEl.classList.toggle('error-text', !!isError); } function showRunSubmissionsShimmer(){ if(!submissionsEl) return; submissionsEl.textContent = ''; for(let i = 0; i < 4; i += 1){ const card = document.createElement('article'); card.className = 'submission-card submission-card--shimmer'; card.setAttribute('aria-hidden', 'true'); submissionsEl.appendChild(card); } } function applyRunPlayerDefault(){ if(!playerNameInput || !fedlServerUsername) return; if(String(playerNameInput.value || '').trim() !== '') return; playerNameInput.value = fedlServerUsername; } 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'; const acct = run.accountUsername ? ` • Account: ${escapeHtml(run.accountUsername)}` : ''; 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())}${acct}

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

`; submissionsEl.appendChild(card); }); } function renderMySavedRuns(){ if(!myRunsSection || !savedRunsListEl) return; if(!fedlServerUserId){ myRunsSection.hidden = true; if(saveAccountBtn) saveAccountBtn.hidden = true; return; } myRunsSection.hidden = false; if(saveAccountBtn) saveAccountBtn.hidden = false; const p = fedlGetAccountPayload(fedlServerUserId); const runs = p.savedRuns || []; savedRunsListEl.textContent = ''; if(!runs.length){ const empty = document.createElement('p'); empty.className = 'muted'; empty.textContent = 'No runs saved yet. Fill the form and use “Save to my account” to keep drafts, or submit to the live queue.'; savedRunsListEl.appendChild(empty); return; } runs.forEach(entry=>{ const card = document.createElement('article'); card.className = 'submission-card run-saved-card'; const top = document.createElement('div'); top.className = 'submission-card-top'; const strong = document.createElement('strong'); strong.textContent = entry.levelTitle || 'Untitled'; top.appendChild(strong); const pill = document.createElement('span'); pill.className = 'status-pill status-pending'; pill.textContent = 'Saved'; top.appendChild(pill); card.appendChild(top); const meta = document.createElement('p'); meta.className = 'submission-meta'; meta.textContent = `${entry.playerName || '—'} • ${entry.percent || '100'}% • ${entry.savedAt ? new Date(entry.savedAt).toLocaleString() : ''}`; card.appendChild(meta); if(entry.videoUrl){ const link = document.createElement('a'); link.className = 'text-link'; link.href = entry.videoUrl; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.textContent = 'Video link'; card.appendChild(link); } const actions = document.createElement('div'); actions.className = 'run-saved-card-actions'; const fillBtn = document.createElement('button'); fillBtn.type = 'button'; fillBtn.className = 'btn ghost-btn small-btn'; fillBtn.textContent = 'Load into form'; fillBtn.addEventListener('click', ()=>{ qs('run-player-name').value = entry.playerName || ''; qs('run-level-title').value = entry.levelTitle || ''; qs('run-video-url').value = entry.videoUrl || ''; qs('run-percent').value = entry.percent || '100'; qs('run-raw-footage-url').value = entry.rawFootageUrl || ''; qs('run-notes').value = entry.notes || ''; setRunFormStatus('Loaded this run into the form. Submit or edit, then save or send to the queue.', false, false); }); const delBtn = document.createElement('button'); delBtn.type = 'button'; delBtn.className = 'btn ghost-btn small-btn'; delBtn.textContent = 'Remove'; delBtn.addEventListener('click', ()=>{ fedlRemoveSavedRun(fedlServerUserId, entry.id); renderMySavedRuns(); }); actions.appendChild(fillBtn); actions.appendChild(delBtn); card.appendChild(actions); savedRunsListEl.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)); showRunSubmissionsShimmer(); setRunListStatus('Loading recent submissions…'); 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); }); } if(saveAccountBtn){ saveAccountBtn.addEventListener('click', ()=>{ const fields = { 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() }; const res = fedlAddSavedRun(fedlServerUserId, fields); if(!res.ok){ setRunFormStatus(res.error, true); return; } setRunFormStatus('Run saved to your account. You can keep multiple saved runs and load them anytime.', false, true); renderMySavedRuns(); }); } document.addEventListener('fedl-auth-updated', ()=>{ renderMySavedRuns(); applyRunPlayerDefault(); }); form.addEventListener('submit', function(event){ event.preventDefault(); if(!canUseLiveServer){ setRunFormStatus('Submitting runs is not available right now.', 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...'); const headers = { 'Content-Type': 'application/json' }; const tok = fedlGetAuthToken(); if(tok){ headers.Authorization = `Bearer ${tok}`; } fetch(liveRunsUrl, { method:'POST', headers, body: JSON.stringify(payload) }).then(async r=>{ if(!r.ok){ const { message } = await fedlReadJsonResponse(r); throw new Error(message); } clearRunsCache(); form.reset(); applyRunPlayerDefault(); const okMsg = fedlServerUsername ? `Run submitted successfully. It is linked to your account (${fedlServerUsername}) for moderators.` : 'Run submitted successfully. The admin panel can review it now.'; setRunFormStatus(okMsg, false, true); return refreshRuns(); }).catch(err=>{ console.error(err); setRunFormStatus(err.message || '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.'); }); fedlRefreshAuthState() .then(()=> fedlPullUserStateToLocal(fedlServerUserId)) .finally(()=>{ renderMySavedRuns(); fedlUpdateAuthNav(); applyRunPlayerDefault(); }); loadRunPage(); } const FEDL_USERNAME_RE = /^[a-z0-9_]{3,24}$/; const FEDL_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const FEDL_AUTH_REDIRECT_MS = 1400; function fedlSetFormStatus(el, msg, kind){ if (!el) { return; } el.textContent = msg || ''; el.className = kind === 'error' ? 'muted error-text' : kind === 'success' ? 'muted success-text' : 'muted'; } if (page === 'signup') { const form = qs('signup-form'); const statusEl = qs('signup-status'); const submitBtn = qs('signup-submit'); function setSignupStatus(msg, kind){ fedlSetFormStatus(statusEl, msg, kind); } function checkTurnstile(){ const turnstileEl = window.turnstile; if(turnstileEl){ const token = turnstileEl.getResponse(); if(!token){ setSignupStatus('Please complete the verification challenge.', 'error'); return false; } } return true; } form.addEventListener('submit', function(ev){ ev.preventDefault(); if(!checkTurnstile()) return; if (!canUseLiveServer) { setSignupStatus('Sign up is not available right now.', 'error'); return; } const username = String(qs('signup-username').value || '').trim().toLowerCase(); const password = qs('signup-password').value || ''; const password2 = qs('signup-password2').value || ''; if (!FEDL_USERNAME_RE.test(username)) { setSignupStatus('Use 3–24 characters: lowercase letters, numbers, or underscore only.', 'error'); return; } if (password.length < 8) { setSignupStatus('Password must be at least 8 characters.', 'error'); return; } if (password !== password2) { setSignupStatus('Passwords do not match.', 'error'); return; } submitBtn.disabled = true; setSignupStatus('Creating account…'); fetch(liveApiPath('/api/auth/signup'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }).then(async r=>{ const { data, message } = await fedlReadJsonResponse(r); if (!r.ok) { throw new Error(message || 'Sign up failed'); } fedlSetAuthToken(data.token); fedlServerUserId = data.userId; fedlServerUsername = data.username; document.dispatchEvent(new CustomEvent('fedl-auth-updated')); setSignupStatus('Account created successfully. Loading your data…', 'success'); return fedlPullUserStateToLocal(data.userId); }).then(()=>{ setSignupStatus('You are signed in. Redirecting…', 'success'); setTimeout(()=>{ const params = new URLSearchParams(window.location.search); const returnUrl = params.get('return') || 'index.html'; window.location.href = returnUrl; }, FEDL_AUTH_REDIRECT_MS); }).catch(err=>{ console.error(err); setSignupStatus(err.message || 'Could not sign up.', 'error'); submitBtn.disabled = false; }); }); } if (page === 'login') { const form = qs('login-form'); const statusEl = qs('login-status'); const submitBtn = qs('login-submit'); function setLoginStatus(msg, kind){ fedlSetFormStatus(statusEl, msg, kind); } function checkTurnstile(){ const turnstileEl = window.turnstile; if(turnstileEl){ const token = turnstileEl.getResponse(); if(!token){ setLoginStatus('Please complete the verification challenge.', 'error'); return false; } } return true; } form.addEventListener('submit', function(ev){ ev.preventDefault(); if(!checkTurnstile()) return; if (!canUseLiveServer) { setLoginStatus('Log in is not available right now.', 'error'); return; } const username = String(qs('login-username').value || '').trim().toLowerCase(); const password = qs('login-password').value || ''; if (!username) { setLoginStatus('Enter your username.', 'error'); return; } submitBtn.disabled = true; setLoginStatus('Signing in…'); fetch(liveApiPath('/api/auth/login'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }).then(async r=>{ const { data, message } = await fedlReadJsonResponse(r); if (!r.ok) { throw new Error(message || 'Log in failed'); } fedlSetAuthToken(data.token); fedlServerUserId = data.userId; fedlServerUsername = data.username; document.dispatchEvent(new CustomEvent('fedl-auth-updated')); setLoginStatus('Signed in successfully. Loading your data…', 'success'); return fedlPullUserStateToLocal(data.userId); }).then(()=>{ setLoginStatus('Welcome back. Redirecting…', 'success'); setTimeout(()=>{ const params = new URLSearchParams(window.location.search); const returnUrl = params.get('return') || 'index.html'; window.location.href = returnUrl; }, FEDL_AUTH_REDIRECT_MS); }).catch(err=>{ console.error(err); setLoginStatus(err.message || 'Could not log in.', 'error'); submitBtn.disabled = false; }); }); } if (page === 'account') { const FEDL_THEME_KEY = 'fedl_theme'; const themeStatusEl = qs('account-theme-status'); const themes = { dark: { '--bg': '#0f1724', '--panel': '#071326', '--accent': '#5cc5ff', '--muted': '#9fb3c8', '--text': '#e6eef8', '--card': '#081220', '--accent-warm': '#ffb84d' }, light: { '--bg': '#f0f4f8', '--panel': '#e2e8f0', '--accent': '#0284c7', '--muted': '#64748b', '--text': '#1e293b', '--card': '#cbd5e1', '--accent-warm': '#f59e0b' }, blue: { '--bg': '#0d1b2a', '--panel': '#1b3a5f', '--accent': '#38bdf8', '--muted': '#94a3b8', '--text': '#e0f2fe', '--card': '#142d4c', '--accent-warm': '#fbbf24' }, midnight: { '--bg': '#0a0a12', '--panel': '#12121f', '--accent': '#a78bfa', '--muted': '#6b7280', '--text': '#e5e7eb', '--card': '#0f0f1a', '--accent-warm': '#f472b6' }, cyberpunk: { '--bg': '#0f0f1a', '--panel': '#1a0a2e', '--accent': '#00ff9f', '--muted': '#b388ff', '--text': '#e0f7fa', '--card': '#150f25', '--accent-warm': '#ff00a8' }, earth: { '--bg': '#1a2f1a', '--panel': '#2d4a2d', '--accent': '#84cc16', '--muted': '#a3c9a3', '--text': '#ecfccb', '--card': '#223d22', '--accent-warm': '#fbbf24' }, retro: { '--bg': '#1a1208', '--panel': '#2b1a0a', '--accent': '#ff9f1c', '--muted': '#c9a66b', '--text': '#ffe4b5', '--card': '#241809', '--accent-warm': '#ff6b35' }, matrix: { '--bg': '#000a00', '--panel': '#001100', '--accent': '#00ff00', '--muted': '#00aa00', '--text': '#00ff00', '--card': '#001100', '--accent-warm': '#88ff88' }, synthwave: { '--bg': '#1a0a2e', '--panel': '#2d1b4e', '--accent': '#ff2a6d', '--muted': '#c792ea', '--text': '#f4e9ff', '--card': '#251440', '--accent-warm': '#05d9e8' }, fire: { '--bg': '#1a0505', '--panel': '#2d0a0a', '--accent': '#ff4500', '--muted': '#cc5500', '--text': '#ffd4b8', '--card': '#250a0a', '--accent-warm': '#ffaa00' }, galaxy: { '--bg': '#0a0612', '--panel': '#150f25', '--accent': '#e056fd', '--muted': '#7c3aed', '--text': '#f0e6ff', '--card': '#0f0818', '--accent-warm': '#f9ca24' }, candy: { '--bg': '#fdf2f8', '--panel': '#fce7f3', '--accent': '#f472b6', '--muted': '#94a3b8', '--text': '#831843', '--card': '#fbcfe8', '--accent-warm': '#34d399' }, highcontrast: { '--bg': '#000000', '--panel': '#111111', '--accent': '#ffffff', '--muted': '#cccccc', '--text': '#ffffff', '--card': '#0a0a0a', '--accent-warm': '#ffff00' }, original: { '--bg': '#0f1724', '--panel': '#071326', '--accent': '#5cc5ff', '--muted': '#9fb3c8', '--text': '#e6eef8', '--card': '#081220', '--accent-warm': '#ffb84d' } }; function applyTheme(name) { const root = document.documentElement; const vars = themes[name]; if (!vars) return; Object.entries(vars).forEach(([k, v]) => root.style.setProperty(k, v)); document.body.dataset.theme = name; } function setThemeStatus(msg, kind) { fedlSetFormStatus(themeStatusEl, msg, kind); } function loadTheme() { const saved = localStorage.getItem(FEDL_THEME_KEY) || 'dark'; applyTheme(saved); document.querySelectorAll('input[name="theme"]').forEach(el => { el.checked = el.value === saved; }); } document.querySelectorAll('input[name="theme"]').forEach(el => { el.addEventListener('change', function() { const name = this.value; localStorage.setItem(FEDL_THEME_KEY, name); applyTheme(name); const activeId = fedlAccountId(); if (activeId) { const accounts = fedlListAccounts(); const account = accounts.find(a => a.id === activeId); if (account) { account.theme = name; fedlSaveAccountsList(accounts); const userData = fedlGetAccountPayload(activeId); userData.theme = name; write(`fedl_user_data_${activeId}`, userData); } } setThemeStatus('Theme saved!', 'success'); }); }); function loadAccountTheme() { const activeId = fedlAccountId(); if (activeId) { const userData = fedlGetAccountPayload(activeId); if (userData && userData.theme) { localStorage.setItem(FEDL_THEME_KEY, userData.theme); applyTheme(userData.theme); } } } loadTheme(); loadAccountTheme(); const overviewStatusEl = qs('account-overview-status'); const accountUsernameEl = qs('account-username'); const accountCreatedEl = qs('account-created'); const resetBtn = qs('account-reset-email-btn'); const resetStatusEl = qs('account-reset-status'); const passwordForm = qs('account-password-form'); const passwordStatusEl = qs('account-password-status'); const passwordSubmit = qs('account-password-submit'); function setOverviewStatus(msg, kind){ fedlSetFormStatus(overviewStatusEl, msg, kind); } function setResetStatus(msg, kind){ fedlSetFormStatus(resetStatusEl, msg, kind); } function setPasswordStatus(msg, kind){ fedlSetFormStatus(passwordStatusEl, msg, kind); } function formatJoinedDate(iso){ if (!iso) return 'Unknown'; const d = new Date(iso); if (Number.isNaN(d.getTime())) return 'Unknown'; return d.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); } function authHeaders(){ return { 'Content-Type': 'application/json', Authorization: `Bearer ${fedlGetAuthToken()}` }; } function loadAccount(){ if (!fedlGetAuthToken()) { window.location.replace('login.html?return=' + encodeURIComponent('account.html')); return Promise.resolve(); } setOverviewStatus('Loading your account…'); return fetch(liveApiPath('/api/account'), { headers: { Authorization: `Bearer ${fedlGetAuthToken()}` }, cache: 'no-store' }).then(async r=>{ const { data, message } = await fedlReadJsonResponse(r); if (!r.ok) { throw new Error(message || 'Could not load account details.'); } if (accountUsernameEl) { accountUsernameEl.textContent = data.username || 'Unknown'; } if (accountCreatedEl) { accountCreatedEl.textContent = formatJoinedDate(data.createdAt); } setOverviewStatus(''); }).catch(err=>{ console.error(err); setOverviewStatus(err.message || 'Could not load your account.', 'error'); if (String(err.message || '').toLowerCase().includes('not signed in')) { window.location.replace('login.html?return=' + encodeURIComponent('account.html')); } }); } if (resetBtn) { resetBtn.addEventListener('click', function(){ resetBtn.disabled = true; setResetStatus('Sending reset code to your messages…'); fetch(liveApiPath('/api/account/password-reset-email'), { method: 'POST', headers: { Authorization: `Bearer ${fedlGetAuthToken()}` } }).then(async r=>{ const { message } = await fedlReadJsonResponse(r); if (!r.ok) { throw new Error(message || 'Could not send reset code.'); } setResetStatus('Reset code sent! Check your messages.', 'success'); }).catch(err=>{ console.error(err); setResetStatus(err.message || 'Could not send reset code.', 'error'); }).finally(()=>{ resetBtn.disabled = false; }); }); } if (passwordForm) { passwordForm.addEventListener('submit', function(ev){ ev.preventDefault(); const currentPassword = String(qs('account-current-password').value || ''); const newPassword = String(qs('account-new-password').value || ''); const confirmPassword = String(qs('account-confirm-password').value || ''); if (!currentPassword) { setPasswordStatus('Enter your current password.', 'error'); return; } if (newPassword.length < 8) { setPasswordStatus('New password must be at least 8 characters.', 'error'); return; } if (newPassword !== confirmPassword) { setPasswordStatus('New passwords do not match.', 'error'); return; } if (passwordSubmit) { passwordSubmit.disabled = true; } setPasswordStatus('Updating password…'); fetch(liveApiPath('/api/account/password'), { method: 'PUT', headers: authHeaders(), body: JSON.stringify({ currentPassword, newPassword }) }).then(async r=>{ const { data, message } = await fedlReadJsonResponse(r); if (!r.ok) { throw new Error(message || 'Could not update password.'); } if (data && data.token) { fedlSetAuthToken(data.token); } passwordForm.reset(); setPasswordStatus('Password updated.', 'success'); }).catch(err=>{ console.error(err); setPasswordStatus(err.message || 'Could not update password.', 'error'); }).finally(()=>{ if (passwordSubmit) { passwordSubmit.disabled = false; } }); }); } loadAccount(); } if (page === 'reset-password') { const requestForm = qs('reset-request-form'); const requestStatusEl = qs('reset-request-status'); const requestSubmit = qs('reset-request-submit'); const requestInput = qs('reset-identifier'); const tokenInput = qs('reset-token'); const resetForm = qs('reset-password-form'); const resetStatusEl = qs('reset-password-status'); const resetSubmit = qs('reset-password-submit'); function setRequestStatus(msg, kind){ fedlSetFormStatus(requestStatusEl, msg, kind); } function setResetPasswordStatus(msg, kind){ fedlSetFormStatus(resetStatusEl, msg, kind); } if (tokenInput) { const params = new URLSearchParams(window.location.search); tokenInput.value = params.get('token') || ''; } if (requestForm) { requestForm.addEventListener('submit', function(ev){ ev.preventDefault(); if (!canUseLiveServer) { setRequestStatus('Password reset is not available right now.', 'error'); return; } const identifier = String(requestInput ? requestInput.value : '').trim().toLowerCase(); if (!identifier) { setRequestStatus('Enter your username or email.', 'error'); return; } if (requestSubmit) { requestSubmit.disabled = true; } setRequestStatus('Checking for your account…'); fetch(liveApiPath('/api/auth/request-password-reset'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier }) }).then(async r=>{ const data = await fedlReadJsonResponse(r); if (!r.ok) { throw new Error(data.message || 'Could not request password reset.'); } setRequestStatus(data.message || 'Check your messages for the reset code.', 'success'); if (requestInput) { requestInput.value = ''; } }).catch(err=>{ console.error(err); setRequestStatus(err.message || 'Could not request password reset.', 'error'); }).finally(()=>{ if (requestSubmit) { requestSubmit.disabled = false; } }); }); } if (resetForm) { resetForm.addEventListener('submit', function(ev){ ev.preventDefault(); if (!canUseLiveServer) { setResetPasswordStatus('Password reset is not available right now.', 'error'); return; } const token = String(tokenInput ? tokenInput.value : '').trim(); const newPassword = String(qs('reset-new-password').value || ''); const confirmPassword = String(qs('reset-confirm-password').value || ''); if (!token) { setResetPasswordStatus('Paste the reset code below.', 'error'); return; } if (newPassword.length < 8) { setResetPasswordStatus('New password must be at least 8 characters.', 'error'); return; } if (newPassword !== confirmPassword) { setResetPasswordStatus('New passwords do not match.', 'error'); return; } if (resetSubmit) { resetSubmit.disabled = true; } setResetPasswordStatus('Resetting your password…'); fetch(liveApiPath('/api/auth/reset-password'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, newPassword }) }).then(async r=>{ const { message } = await fedlReadJsonResponse(r); if (!r.ok) { throw new Error(message || 'Could not reset password.'); } resetForm.reset(); if (tokenInput) { tokenInput.value = ''; } setResetPasswordStatus('Password reset complete. You can log in with your new password now.', 'success'); }).catch(err=>{ console.error(err); setResetPasswordStatus(err.message || 'Could not reset password.', 'error'); }).finally(()=>{ if (resetSubmit) { resetSubmit.disabled = false; } }); }); } } if(page==='contact'){ const form = qs('contact-form'); const formStatusEl = qs('contact-form-status'); const categoryEl = qs('contact-category'); const subjectEl = qs('contact-subject'); const descriptionEl = qs('contact-description'); const emailEl = qs('contact-email'); function setContactFormStatus(message, isError){ if(!formStatusEl) return; formStatusEl.textContent = message; if(isError){ formStatusEl.classList.add('error-text'); formStatusEl.classList.remove('success-text'); }else{ formStatusEl.classList.remove('error-text'); formStatusEl.classList.add('success-text'); } } if(form){ form.addEventListener('submit', function(event){ event.preventDefault(); if(!canUseLiveServer){ setContactFormStatus('Reports are not available right now.', true); return; } const payload = { category: categoryEl ? categoryEl.value : 'other', subject: subjectEl ? subjectEl.value.trim() : '', description: descriptionEl ? descriptionEl.value.trim() : '', email: emailEl ? emailEl.value.trim() : '' }; if(!payload.subject || !payload.description){ setContactFormStatus('Subject and description are required.', true); return; } setContactFormStatus('Submitting your report...'); const headers = { 'Content-Type': 'application/json' }; const tok = fedlGetAuthToken(); if(tok){ headers.Authorization = `Bearer ${tok}`; } fetch(`${liveServerBase}/api/bugreports`, { method:'POST', headers, body: JSON.stringify(payload) }).then(async r=>{ if(!r.ok){ const { message } = await fedlReadJsonResponse(r); throw new Error(message || 'Submit failed'); } return r.json(); }).then(()=>{ setContactFormStatus('Thank you! Your report has been submitted. The admins will review it soon.'); if(form) form.reset(); }).catch(err=>{ console.error(err); setContactFormStatus(err.message || 'Could not submit your report. Try again later.', true); }); }); } } injectFedlAuthNav(); fedlRefreshAuthState().finally(()=>{ fedlUpdateAuthNav(); if ((page === 'signup' || page === 'login') && fedlServerUsername) { window.location.replace('index.html'); } }); // Utility function escapeHtml(s){return String(s).replace(/[&<>"']/g, c=>({"&":"&","<":"<",">":">","\"":""","'":"'"})[c])} function escapeAttr(s){return escapeHtml(String(s == null ? '' : s))} })();