// 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=>`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 = '' + labelText + '
' + 'The homepage preview could not load any FEDL entries yet.
${item.url ? 'Video link is ready from the list page.' : 'This entry does not have a linked video yet.'}
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 = '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 = 'The live queue is empty right now.
${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))} })();