diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8e06db --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Server-side auth and user data (local only) +server/users.json +server/sessions.json +server/userdata.json diff --git a/app.js b/app.js index 22017fd..bce1003 100644 --- a/app.js +++ b/app.js @@ -57,6 +57,376 @@ } function write(key, val){localStorage.setItem(key,JSON.stringify(val))} + 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() }; + } + + 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 fedlUpdateAuthNav(){ + const wrap = document.querySelector('.fedl-auth-nav'); + if (!wrap) { + return; + } + wrap.textContent = ''; + if (fedlServerUsername) { + 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(' ')); + 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'); + a1.href = 'login.html'; + 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()); @@ -252,6 +622,53 @@ 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 openVideoModal(item, options){ const config = Object.assign({showRuns:false}, options || {}); const url = item && item.url; @@ -286,6 +703,7 @@ if(config.showRuns && runsList){ renderApprovedRunsForLevel(item, runsList); } + updateAccountProgressInModal(modal, item); modal.style.display = 'flex'; } @@ -354,8 +772,145 @@ 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 to this server (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}`; @@ -374,8 +929,148 @@ 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'; @@ -383,6 +1078,8 @@ 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.'; @@ -714,9 +1411,14 @@ let controlsBound = false; // Load hard-coded data file data.txt (category|position|title|url per line) function loadData(){ - loadItems().then(items=>{ - applyItems(items); - }).catch(err=>{listArea.innerHTML='

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

'; console.error(err)}); + const run = ()=>{ + loadItems().then(items=>{ + applyItems(items); + }).catch(err=>{listArea.innerHTML='

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

'; console.error(err)}); + }; + fedlRefreshAuthState() + .then(()=> fedlPullUserStateToLocal(fedlServerUserId)) + .finally(run); } function computeCategories(items){ @@ -779,15 +1481,36 @@ }).sort((a,b)=> (Number(a.position)||0)-(Number(b.position)||0)); const tbody = qs('list-area'); tbody.innerHTML=''; + const accId = fedlDataUserId(); 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(tdAct); + tr.appendChild(tdNum); tr.appendChild(tdTitle); tr.appendChild(tdPct); tr.appendChild(tdAct); tbody.appendChild(tr); }); } @@ -812,6 +1535,9 @@ // 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); @@ -1566,11 +2292,15 @@ 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'); - function setRunFormStatus(message, isError){ + 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){ @@ -1588,12 +2318,15 @@ 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())}

+

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

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

- +
#TitleAction
#TitleMy %Action
diff --git a/login.html b/login.html new file mode 100644 index 0000000..c23557a --- /dev/null +++ b/login.html @@ -0,0 +1,43 @@ + + + + + + Log in — GD fedl + + + +
+

Log in

+ +
+
+
+

FEDL account

+

Welcome back

+

Sign in to load your saved roulette pick and per-level progress from the server.

+
+ + +

+ +
+
+
+ + + diff --git a/players.html b/players.html index 31c3958..14aa33d 100644 --- a/players.html +++ b/players.html @@ -12,11 +12,11 @@
diff --git a/roulette.html b/roulette.html index 589b51b..29943b3 100644 --- a/roulette.html +++ b/roulette.html @@ -12,15 +12,57 @@
+
+
@@ -37,9 +79,19 @@

Rank: -

Level ID: -

Level IDs come from your local file first, then API lookup if needed.

+
+
+ +
diff --git a/server/server.js b/server/server.js index b453a81..c20d283 100644 --- a/server/server.js +++ b/server/server.js @@ -1,11 +1,15 @@ const http = require('http'); const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const { URL } = require('url'); const appRoot = path.resolve(__dirname, '..'); const dataPath = path.join(__dirname, 'data.txt'); const runsPath = path.join(__dirname, 'runs.json'); +const usersPath = path.join(__dirname, 'users.json'); +const sessionsPath = path.join(__dirname, 'sessions.json'); +const userDataPath = path.join(__dirname, 'userdata.json'); const port = Number(process.env.PORT) || 8090; const host = process.env.HOST || '127.0.0.1'; const BASE = '/fedl'; @@ -107,6 +111,136 @@ function writeRuns(runs) { fs.writeFileSync(runsPath, `${JSON.stringify(runs, null, 2)}\n`, 'utf8'); } +const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +function ensureUsersFile() { + if (!fs.existsSync(usersPath)) { + fs.writeFileSync(usersPath, '[]\n', 'utf8'); + } +} + +function ensureSessionsFile() { + if (!fs.existsSync(sessionsPath)) { + fs.writeFileSync(sessionsPath, '{}\n', 'utf8'); + } +} + +function ensureUserDataFile() { + if (!fs.existsSync(userDataPath)) { + fs.writeFileSync(userDataPath, '{}\n', 'utf8'); + } +} + +function readUsers() { + ensureUsersFile(); + const raw = fs.readFileSync(usersPath, 'utf8'); + const parsed = JSON.parse(raw || '[]'); + return Array.isArray(parsed) ? parsed : []; +} + +function writeUsers(users) { + fs.writeFileSync(usersPath, `${JSON.stringify(users, null, 2)}\n`, 'utf8'); +} + +function readSessionsRaw() { + ensureSessionsFile(); + const raw = fs.readFileSync(sessionsPath, 'utf8'); + const parsed = JSON.parse(raw || '{}'); + return parsed && typeof parsed === 'object' ? parsed : {}; +} + +function writeSessions(sessions) { + fs.writeFileSync(sessionsPath, `${JSON.stringify(sessions, null, 2)}\n`, 'utf8'); +} + +function cleanSessions(sessions) { + const now = Date.now(); + const out = {}; + Object.keys(sessions).forEach(token => { + const s = sessions[token]; + if (s && s.expiresAt && new Date(s.expiresAt).getTime() > now) { + out[token] = s; + } + }); + return out; +} + +function readSessions() { + return cleanSessions(readSessionsRaw()); +} + +function createSession(userId, username) { + const sessions = readSessions(); + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString(); + sessions[token] = { userId, username, expiresAt }; + writeSessions(sessions); + return token; +} + +function findSession(token) { + if (!token) { + return null; + } + const sessions = readSessions(); + const s = sessions[token]; + if (!s || new Date(s.expiresAt).getTime() <= Date.now()) { + return null; + } + return s; +} + +function deleteSession(token) { + const sessions = readSessionsRaw(); + delete sessions[token]; + writeSessions(cleanSessions(sessions)); +} + +function hashPassword(password) { + const salt = crypto.randomBytes(16).toString('hex'); + const hash = crypto.scryptSync(password, salt, 64).toString('hex'); + return { salt, hash }; +} + +function verifyPassword(password, salt, hashHex) { + try { + const h = crypto.scryptSync(password, salt, 64).toString('hex'); + const a = Buffer.from(h, 'hex'); + const b = Buffer.from(hashHex, 'hex'); + if (a.length !== b.length) { + return false; + } + return crypto.timingSafeEqual(a, b); + } catch (error) { + return false; + } +} + +function normalizeUsername(u) { + return String(u || '').trim().toLowerCase(); +} + +function usernameOk(u) { + return /^[a-z0-9_]{3,24}$/.test(u); +} + +function getBearerToken(req) { + const h = String(req.headers.authorization || ''); + const m = h.match(/^Bearer\s+(\S+)/i); + return m ? m[1] : ''; +} + +function readUserDataMap() { + ensureUserDataFile(); + const raw = fs.readFileSync(userDataPath, 'utf8'); + const parsed = JSON.parse(raw || '{}'); + return parsed && typeof parsed === 'object' ? parsed : {}; +} + +function writeUserDataMap(map) { + fs.writeFileSync(userDataPath, `${JSON.stringify(map, null, 2)}\n`, 'utf8'); +} + function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -473,10 +607,73 @@ function normalizeRun(payload, existingRun) { reviewedBy: String(payload.reviewedBy || existingRun?.reviewedBy || '').trim(), reviewNotes: String(payload.reviewNotes || existingRun?.reviewNotes || '').trim(), submittedAt: existingRun?.submittedAt || new Date().toISOString(), - updatedAt: new Date().toISOString() + updatedAt: new Date().toISOString(), + accountUserId: existingRun?.accountUserId || '', + accountUsername: existingRun?.accountUsername || '' }; } +const MAX_SAVED_RUNS_PER_USER = 48; + +function sanitizeSavedRuns(raw) { + if (!Array.isArray(raw)) { + return []; + } + const out = []; + for (let i = 0; i < raw.length && out.length < MAX_SAVED_RUNS_PER_USER; i += 1) { + const item = raw[i]; + if (!item || typeof item !== 'object') continue; + const playerName = String(item.playerName || '').trim().slice(0, 120); + const levelTitle = String(item.levelTitle || '').trim().slice(0, 280); + if (!playerName || !levelTitle) continue; + out.push({ + id: String(item.id || `sv_${Date.now().toString(36)}_${i}_${Math.random().toString(36).slice(2, 8)}`).slice(0, 96), + playerName, + levelTitle, + videoUrl: String(item.videoUrl || '').trim().slice(0, 2048), + percent: String(item.percent != null ? item.percent : '100').trim().slice(0, 12) || '100', + rawFootageUrl: String(item.rawFootageUrl || '').trim().slice(0, 2048), + notes: String(item.notes || '').trim().slice(0, 8000), + savedAt: String(item.savedAt || new Date().toISOString()).slice(0, 48) + }); + } + return out; +} + +const ROULETTE_SLOT_KEYS = ['1', '2', '3']; + +function emptyRouletteSlots() { + return { '1': null, '2': null, '3': null }; +} + +function sanitizeRouletteSlots(raw) { + const out = emptyRouletteSlots(); + if (!raw || typeof raw !== 'object') { + return out; + } + for (const k of ROULETTE_SLOT_KEYS) { + const v = raw[k]; + if (v == null || typeof v !== 'object') { + continue; + } + const title = String(v.title || '').trim().slice(0, 280); + if (!title) { + continue; + } + out[k] = { + title, + position: String(v.position || '').trim().slice(0, 32), + level: String(v.level || '').trim().slice(0, 120), + url: String(v.url || '').trim().slice(0, 2048), + levelId: String(v.levelId || '').trim().slice(0, 64), + noteSource: v.noteSource === 'api' ? 'api' : 'file', + percent: String(v.percent != null ? v.percent : '').trim().slice(0, 12), + savedAt: String(v.savedAt || new Date().toISOString()).slice(0, 48) + }; + } + return out; +} + function serveFile(reqPath, res) { let filePath = path.join(appRoot, reqPath === '/' ? 'index.html' : reqPath.slice(1)); filePath = path.normalize(filePath); @@ -521,6 +718,153 @@ const server = http.createServer((req, res) => { return; } + if (req.method === 'POST' && pathname === '/api/auth/signup') { + let body = ''; + req.on('data', chunk => { + body += chunk; + if (body.length > 65536) req.destroy(); + }); + req.on('end', () => { + try { + const payload = JSON.parse(body || '{}'); + const username = normalizeUsername(payload.username); + const password = String(payload.password || ''); + if (!usernameOk(username)) { + sendJson(res, 400, { + error: 'Username must be 3-24 characters: lowercase letters, numbers, or underscore.' + }); + return; + } + if (password.length < 8) { + sendJson(res, 400, { error: 'Password must be at least 8 characters.' }); + return; + } + const users = readUsers(); + if (users.some(u => u.username === username)) { + sendJson(res, 409, { error: 'That username is already taken.' }); + return; + } + const { salt, hash } = hashPassword(password); + const id = `usr_${Date.now().toString(36)}${crypto.randomBytes(4).toString('hex')}`; + users.push({ + id, + username, + passwordHash: hash, + salt, + createdAt: new Date().toISOString() + }); + writeUsers(users); + const token = createSession(id, username); + sendJson(res, 201, { ok: true, token, userId: id, username }); + } catch (error) { + sendJson(res, 400, { error: 'Invalid signup request' }); + } + }); + return; + } + + if (req.method === 'POST' && pathname === '/api/auth/login') { + let body = ''; + req.on('data', chunk => { + body += chunk; + if (body.length > 65536) req.destroy(); + }); + req.on('end', () => { + try { + const payload = JSON.parse(body || '{}'); + const username = normalizeUsername(payload.username); + const password = String(payload.password || ''); + const users = readUsers(); + const user = users.find(u => u.username === username); + if (!user || !verifyPassword(password, user.salt, user.passwordHash)) { + sendJson(res, 401, { error: 'Invalid username or password.' }); + return; + } + const token = createSession(user.id, user.username); + sendJson(res, 200, { ok: true, token, userId: user.id, username: user.username }); + } catch (error) { + sendJson(res, 400, { error: 'Invalid login request' }); + } + }); + return; + } + + if (req.method === 'POST' && pathname === '/api/auth/logout') { + const token = getBearerToken(req); + if (token) { + deleteSession(token); + } + sendJson(res, 200, { ok: true }); + return; + } + + if (req.method === 'GET' && pathname === '/api/auth/me') { + const token = getBearerToken(req); + const sess = findSession(token); + if (!sess) { + sendJson(res, 401, { error: 'Not signed in' }); + return; + } + sendJson(res, 200, { userId: sess.userId, username: sess.username }); + return; + } + + if (req.method === 'GET' && pathname === '/api/user/state') { + const token = getBearerToken(req); + const sess = findSession(token); + if (!sess) { + sendJson(res, 401, { error: 'Not signed in' }); + return; + } + const map = readUserDataMap(); + const row = map[sess.userId] || {}; + const data = { + roulettePick: row.roulettePick != null ? row.roulettePick : null, + levelPercents: row.levelPercents && typeof row.levelPercents === 'object' ? row.levelPercents : {}, + savedRuns: Array.isArray(row.savedRuns) ? row.savedRuns : [], + rouletteSlots: sanitizeRouletteSlots(row.rouletteSlots) + }; + sendJson(res, 200, { data }); + return; + } + + if (req.method === 'PUT' && pathname === '/api/user/state') { + const token = getBearerToken(req); + const sess = findSession(token); + if (!sess) { + sendJson(res, 401, { error: 'Not signed in' }); + return; + } + let body = ''; + req.on('data', chunk => { + body += chunk; + if (body.length > 2 * 1024 * 1024) req.destroy(); + }); + req.on('end', () => { + try { + const payload = JSON.parse(body || '{}'); + const incoming = payload.data; + if (!incoming || typeof incoming !== 'object') { + sendJson(res, 400, { error: 'A "data" object is required' }); + return; + } + const map = readUserDataMap(); + map[sess.userId] = { + roulettePick: incoming.roulettePick != null ? incoming.roulettePick : null, + levelPercents: + incoming.levelPercents && typeof incoming.levelPercents === 'object' ? incoming.levelPercents : {}, + savedRuns: sanitizeSavedRuns(incoming.savedRuns), + rouletteSlots: sanitizeRouletteSlots(incoming.rouletteSlots) + }; + writeUserDataMap(map); + sendJson(res, 200, { ok: true }); + } catch (error) { + sendJson(res, 400, { error: 'Invalid payload' }); + } + }); + return; + } + if (req.method === 'GET' && pathname === '/api/list') { try { const text = readDataText(); @@ -586,6 +930,11 @@ const server = http.createServer((req, res) => { try { const payload = JSON.parse(body || '{}'); const nextRun = normalizeRun(payload); + const sess = findSession(getBearerToken(req)); + if (sess) { + nextRun.accountUserId = sess.userId; + nextRun.accountUsername = sess.username; + } if (!nextRun.playerName || !nextRun.levelTitle || !nextRun.videoUrl || !nextRun.percent) { sendJson(res, 400, { error: 'playerName, levelTitle, videoUrl, and percent are required' }); return; diff --git a/signup.html b/signup.html new file mode 100644 index 0000000..f266bfc --- /dev/null +++ b/signup.html @@ -0,0 +1,48 @@ + + + + + + Sign up — GD fedl + + + +
+

Sign up

+ +
+
+
+

FEDL account

+

Create an account

+

Username and password are stored on your FEDL server. Roulette progress and list % sync to the server when you are signed in.

+
+ +

3–24 characters, lowercase letters, numbers, or underscore only.

+ + +

+
+ + Already have an account? Log in +
+
+
+
+ + + diff --git a/styles.css b/styles.css index a0d32d3..1c5d13a 100644 --- a/styles.css +++ b/styles.css @@ -31,6 +31,8 @@ table.levels-table tr:hover td{background:rgba(255,255,255,0.01)} .btn:hover{transform:translateY(-1px);box-shadow:0 10px 24px rgba(92,197,255,0.22)} .muted{color:var(--muted);font-size:0.9rem} .error-text{color:#ff9f9f} +.success-text{color:#8cf0ae!important} +header nav a.btn{margin-left:14px;text-decoration:none;vertical-align:middle} .small-btn{padding:8px 12px} .danger-btn{background:#ff7b7b;color:#2a0909} .admin-shell{padding:18px 20px} @@ -184,6 +186,34 @@ table.levels-table .btn{padding:9px 14px} /* small helpers reused */ .small{font-size:0.9rem} .roulette-shell{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr);gap:18px;align-items:start} +.roulette-main-stack{display:grid;gap:18px;min-width:0} +.roulette-account-panel{padding:22px 24px;border-radius:28px;background:linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02));border:1px solid rgba(255,255,255,0.08)} +.roulette-account-heading{margin:0 0 8px 0;font-size:1.35rem} +.roulette-account-intro{margin:0 0 14px;line-height:1.55} +.roulette-account-controls{display:grid;gap:10px} +.roulette-account-label{font-size:0.88rem;font-weight:700;color:var(--muted)} +.roulette-account-select{width:100%;max-width:320px;padding:10px 12px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:var(--text);font:inherit} +.roulette-account-create-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center} +.roulette-account-create-row input{flex:1;min-width:160px;padding:10px 12px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:var(--text);font:inherit} +.roulette-restore-btn{margin-top:12px} +.roulette-login-sync-hint{margin:0 0 14px;line-height:1.55} +.roulette-login-sync-hint a{color:var(--accent)} +.roulette-slots-block{margin:14px 0;padding:14px 0;border-top:1px solid rgba(255,255,255,0.08);border-bottom:1px solid rgba(255,255,255,0.08)} +.roulette-slots-heading{margin:0 0 6px 0;font-size:0.95rem;font-weight:800;color:#eaf2fb} +.roulette-slot-row{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:10px} +.roulette-slot-title{min-width:52px;font-weight:700;font-size:0.88rem;color:var(--muted)} +.roulette-slot-label{flex:1;min-width:140px;font-size:0.88rem} +.roulette-progress-block{margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.08)} +.roulette-progress-label{display:block;font-size:0.88rem;font-weight:700;margin-bottom:8px;color:#dce8f5} +.roulette-progress-input-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center} +.roulette-progress-input-row input{width:100px;padding:10px 12px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:var(--text);font:inherit} +.roulette-percent-hint{margin:10px 0 0;line-height:1.55;max-width:52ch} +.modal-account-progress{margin:16px 0;padding:14px 16px;border-radius:14px;background:rgba(92,197,255,0.08);border:1px solid rgba(92,197,255,0.15)} +.modal-account-label{margin:0 0 8px 0;font-size:0.9rem;font-weight:700} +.modal-account-row{display:flex;gap:10px;align-items:center} +.modal-account-pct-input{width:100px;padding:8px 10px;border-radius:10px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.05);color:var(--text);font:inherit} +.list-my-progress-cell{vertical-align:middle} +.list-progress-input{width:100%;max-width:88px;padding:8px 10px;border-radius:10px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:var(--text);font:inherit;text-align:center} .roulette-main-column{display:grid;gap:18px} .roulette-rules{margin-bottom:18px} .roulette-panel{margin-top:0;padding:28px;border-radius:28px;background:linear-gradient(135deg,rgba(92,197,255,0.14),rgba(7,19,38,0.96));border:1px solid rgba(92,197,255,0.18)} @@ -233,6 +263,9 @@ table.levels-table .btn{padding:9px 14px} .submission-panel{margin-top:18px;padding:22px 22px 16px;border-radius:26px;background:linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02));border:1px solid rgba(255,255,255,0.06)} .submission-header{margin-bottom:18px} .submission-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px} +.run-my-runs-panel{margin-top:8px} +.run-my-runs-head{margin-bottom:14px} +.run-saved-card-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px} .submission-card{padding:18px;border-radius:18px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08)} .submission-card-top{display:flex;align-items:center;justify-content:space-between;gap:12px} .submission-card strong{font-size:1.05rem} @@ -284,6 +317,15 @@ linear-gradient(180deg,rgba(8,18,32,0.98),rgba(7,19,38,0.94))} .error-actions{display:flex;flex-wrap:wrap;gap:12px;margin-top:24px} .error-actions .btn{font-weight:700;text-decoration:none} .ghost-btn{background:transparent;color:var(--text);border:1px solid rgba(255,255,255,0.14)} +.auth-main{max-width:520px;margin:0 auto;padding:28px 20px 48px} +.auth-panel{padding:28px 26px} +.auth-panel h2{margin:0 0 8px 0;font-size:1.45rem} +.auth-form{display:grid;gap:16px;margin-top:18px} +.auth-form .auth-hint{margin:-6px 0 0 0;font-size:.82rem} +.auth-actions{display:flex;flex-wrap:wrap;align-items:center;gap:14px;margin-top:8px} +header nav .fedl-auth-nav{margin-left:14px;display:inline-flex;align-items:center;flex-wrap:wrap;gap:8px 10px;vertical-align:middle} +header nav .fedl-auth-nav a{color:var(--muted)} +header nav .fedl-auth-nav a:hover{color:var(--text)} .error-side{display:flex;flex-direction:column;justify-content:space-between;gap:18px;padding:34px 30px;background:linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))} .error-side h3{margin:0 0 10px 0;font-size:1rem;letter-spacing:0.04em;text-transform:uppercase;color:#eef6ff} .error-list{margin:0;padding:0;list-style:none} @@ -300,4 +342,4 @@ linear-gradient(180deg,rgba(8,18,32,0.98),rgba(7,19,38,0.94))} @media(max-width:900px){.layout{flex-direction:column}.sidebar{width:100%}.sidebar .panel{height:auto}.video-modal iframe{height:320px}.modal-runs-head,.modal-run-card{grid-template-columns:1fr}.home-hero{grid-template-columns:1fr;min-height:auto}.home-copy,.hero-stage{padding:24px 22px}.hero-stats,.home-section-grid,.spotlight-grid{grid-template-columns:1fr}.spotlight-panel{grid-template-columns:1fr;padding:24px 22px}.list-hero-panel,.table-header,.search-row,.roulette-head,.guess-controls{flex-direction:column;align-items:flex-start}.control-select{max-width:none;width:100%}.roulette-shell{grid-template-columns:1fr}.discord-panel{flex-direction:column;align-items:flex-start;padding:22px 20px}.discord-panel h2{font-size:1.55rem}.discord-actions{width:100%;justify-content:flex-start}.rules-view header{padding:16px 18px}.rules-view header h1{font-size:1.55rem}.rules-page{padding:24px 20px}.rules-page h2{font-size:1.65rem}.guess-controls input,.guess-controls .btn{width:100%}} @media(max-width:900px){.error-grid{grid-template-columns:1fr}.error-copy,.error-side{padding:24px 20px}.error-copy h2{font-size:2.5rem}} @media(max-width:900px){.admin-overview-panel,.admin-toolbar{flex-direction:column;align-items:stretch}.admin-actions{flex-direction:column;align-items:stretch}.admin-password-field input,.admin-actions input[type=text],.admin-actions input[type=password]{min-width:0;width:100%}.run-shell{grid-template-columns:1fr}.run-head{flex-direction:column;align-items:flex-start}.run-form{grid-template-columns:1fr}.admin-login-screen{padding:18px}.admin-login-card{padding:26px 22px}} -@media(max-width:640px){header{flex-direction:column;align-items:flex-start;gap:12px}nav{display:flex;flex-wrap:wrap;gap:10px}nav a{margin-left:0}.home-copy h2{max-width:none}.hero-actions{flex-direction:column;align-items:stretch}.hero-actions .btn,.roulette-btn{width:100%;text-align:center}.list-hero-panel,.list-controls-panel,.list-table-panel,.roulette-panel,.roulette-rules-card,.run-panel,.run-side-panel,.submission-panel{padding:22px 18px}.table-hint{text-align:left}.run-admin-actions{justify-content:flex-start}} +@media(max-width:640px){header{flex-direction:column;align-items:flex-start;gap:12px}nav{display:flex;flex-wrap:wrap;gap:10px}nav a{margin-left:0}header nav a.btn{margin-left:0}.home-copy h2{max-width:none}.hero-actions{flex-direction:column;align-items:stretch}.hero-actions .btn,.roulette-btn{width:100%;text-align:center}.list-hero-panel,.list-controls-panel,.list-table-panel,.roulette-panel,.roulette-rules-card,.run-panel,.run-side-panel,.submission-panel{padding:22px 18px}.table-hint{text-align:left}.run-admin-actions{justify-content:flex-start}}