0d971745a6
- 4 practice modes: Timed (SVG ring), Endless (3 lives), Category Drill, Speed Run
- Practice settings modal (timer duration, difficulty) with localStorage persistence
- History view: paginated table, SVG score chart, best-times-per-stratagem
- ELO rating system with server-side K=32 calculation and rank tiers PRIVATE–GENERAL
- Post-match result modal with ELO delta and round history
- Challenge modal showing challenger name + ELO (replaces toast)
- Dashboard hero card with ELO rank icon and daily sequence preview
- Leaderboard tabs: Practice Score / ELO / Speed Run
- Mobile hamburger nav drawer with slide-in animation
- DB migration: elo column, mode column, stratagem_stats table
- WS lobby-update now sends {name, elo, rank} objects
- View fade transitions, danger vignette at ≤5s, streak fire glow, combo badge
- Esc/Enter keyboard shortcuts for modals and practice
819 lines
29 KiB
JavaScript
819 lines
29 KiB
JavaScript
'use strict';
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────────
|
||
const state = {
|
||
user: null, // { user, role, mustChange }
|
||
currentView: 'login',
|
||
stratagems: [],
|
||
|
||
practice: {
|
||
active: false,
|
||
current: null,
|
||
progress: 0,
|
||
timeLeft: 30,
|
||
timerHandle: null,
|
||
startTime: null,
|
||
score: 0,
|
||
streak: 0,
|
||
selectedCats: new Set(), // empty = all categories
|
||
dailyTarget: null, // set when using daily challenge shortcut
|
||
},
|
||
|
||
lobby: {
|
||
online: [],
|
||
incoming: [], // usernames who challenged me
|
||
},
|
||
|
||
match: {
|
||
roomId: null,
|
||
opponent: null,
|
||
matchScores: {},
|
||
current: null,
|
||
myProgress: 0,
|
||
oppProgress: 0,
|
||
roundActive: false,
|
||
},
|
||
|
||
ws: null,
|
||
wsReconnectTimer: null,
|
||
};
|
||
|
||
// ── API helpers ───────────────────────────────────────────────────────────────
|
||
async function api(method, endpoint, body) {
|
||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||
const res = await fetch('/api' + endpoint, opts);
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||
return data;
|
||
}
|
||
|
||
// ── View system ───────────────────────────────────────────────────────────────
|
||
function showView(name) {
|
||
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
|
||
const el = document.getElementById('view-' + name);
|
||
if (el) el.classList.remove('hidden');
|
||
state.currentView = name;
|
||
|
||
// Highlight active nav button
|
||
document.querySelectorAll('.nav-btn').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.view === name);
|
||
});
|
||
|
||
// Stop practice timer when navigating away
|
||
if (name !== 'practice') stopPracticeTimer();
|
||
|
||
// View-specific init
|
||
if (name === 'dashboard') loadDashboard();
|
||
if (name === 'leaderboard') loadLeaderboard();
|
||
if (name === 'admin') loadAdmin();
|
||
if (name === 'practice') initPracticeView();
|
||
if (name === 'lobby') updateLobbyView();
|
||
}
|
||
|
||
// ── Authentication ────────────────────────────────────────────────────────────
|
||
async function checkAuth() {
|
||
try {
|
||
const data = await api('GET', '/me');
|
||
if (data.user) {
|
||
state.user = data;
|
||
if (data.mustChange) {
|
||
showView('change-password');
|
||
} else {
|
||
onLoggedIn();
|
||
}
|
||
} else {
|
||
showView('login');
|
||
}
|
||
} catch {
|
||
showView('login');
|
||
}
|
||
}
|
||
|
||
async function onLoggedIn() {
|
||
document.getElementById('main-nav').classList.remove('hidden');
|
||
document.getElementById('nav-username').textContent = state.user.user;
|
||
document.getElementById('nav-admin').classList.toggle('hidden', state.user.role !== 'admin');
|
||
// Stratagems are served via authenticated API – not as a public static file
|
||
state.stratagems = await api('GET', '/stratagems').catch(() => []);
|
||
connectWS();
|
||
showView('dashboard');
|
||
}
|
||
|
||
async function logout() {
|
||
stopPracticeTimer();
|
||
if (state.ws) state.ws.close();
|
||
clearTimeout(state.wsReconnectTimer);
|
||
await api('POST', '/logout').catch(() => {});
|
||
state.user = null;
|
||
document.getElementById('main-nav').classList.add('hidden');
|
||
showView('login');
|
||
}
|
||
|
||
// Login form
|
||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const el = document.getElementById('login-error');
|
||
el.classList.add('hidden');
|
||
try {
|
||
await api('POST', '/login', {
|
||
username: document.getElementById('login-username').value.trim(),
|
||
password: document.getElementById('login-password').value,
|
||
});
|
||
await checkAuth();
|
||
} catch (err) {
|
||
el.textContent = err.message;
|
||
el.classList.remove('hidden');
|
||
}
|
||
});
|
||
|
||
// Change password form
|
||
document.getElementById('change-password-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const errEl = document.getElementById('cp-error');
|
||
const newPw = document.getElementById('cp-new').value;
|
||
const confPw = document.getElementById('cp-confirm').value;
|
||
errEl.classList.add('hidden');
|
||
|
||
if (newPw !== confPw) {
|
||
errEl.textContent = 'Passwords do not match';
|
||
errEl.classList.remove('hidden');
|
||
return;
|
||
}
|
||
try {
|
||
await api('POST', '/change-password', {
|
||
oldPassword: document.getElementById('cp-old').value,
|
||
newPassword: newPw,
|
||
});
|
||
state.user.mustChange = false;
|
||
onLoggedIn();
|
||
} catch (err) {
|
||
errEl.textContent = err.message;
|
||
errEl.classList.remove('hidden');
|
||
}
|
||
});
|
||
|
||
// Nav buttons
|
||
document.querySelectorAll('.nav-btn[data-view]').forEach(btn => {
|
||
btn.addEventListener('click', () => showView(btn.dataset.view));
|
||
});
|
||
|
||
// ── WebSocket ─────────────────────────────────────────────────────────────────
|
||
function connectWS() {
|
||
if (state.ws) return;
|
||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
state.ws = new WebSocket(proto + '//' + location.host);
|
||
|
||
state.ws.onopen = () => { clearTimeout(state.wsReconnectTimer); };
|
||
state.ws.onmessage = (e) => { try { handleWSMessage(JSON.parse(e.data)); } catch {} };
|
||
state.ws.onerror = () => state.ws.close();
|
||
state.ws.onclose = () => {
|
||
state.ws = null;
|
||
if (state.user) {
|
||
state.wsReconnectTimer = setTimeout(connectWS, 3000);
|
||
}
|
||
};
|
||
}
|
||
|
||
function wsSend(type, payload) {
|
||
if (state.ws?.readyState === WebSocket.OPEN) {
|
||
state.ws.send(JSON.stringify({ type, payload: payload || {} }));
|
||
}
|
||
}
|
||
|
||
function handleWSMessage({ type, payload }) {
|
||
switch (type) {
|
||
case 'lobby-update':
|
||
state.lobby.online = payload.online || [];
|
||
state.lobby.incoming = payload.incoming || [];
|
||
if (state.currentView === 'lobby') updateLobbyView();
|
||
if (state.currentView === 'dashboard') updateDashboardOnline(payload.online);
|
||
updateChallengeBadge();
|
||
break;
|
||
|
||
case 'challenge-received':
|
||
if (!state.lobby.incoming.includes(payload.from)) state.lobby.incoming.push(payload.from);
|
||
updateChallengeBadge();
|
||
if (state.currentView === 'lobby') updateLobbyView();
|
||
showToast(esc(payload.from) + ' challenges you! Go to 1v1 to respond.');
|
||
break;
|
||
|
||
case 'challenge-declined':
|
||
showToast(esc(payload.by) + ' declined your challenge.');
|
||
break;
|
||
|
||
case 'room-joined':
|
||
state.match.roomId = payload.roomId;
|
||
state.match.opponent = payload.opponent;
|
||
state.match.matchScores = payload.matchScores;
|
||
state.match.myProgress = 0;
|
||
state.match.oppProgress = 0;
|
||
state.match.roundActive = false;
|
||
showView('match');
|
||
renderMatchWaiting();
|
||
break;
|
||
|
||
case 'round-start':
|
||
state.match.current = payload.stratagem;
|
||
state.match.myProgress = 0;
|
||
state.match.oppProgress = 0;
|
||
state.match.roundActive = true;
|
||
renderMatchRound();
|
||
break;
|
||
|
||
case 'input-result':
|
||
if (payload.userId === state.user.user) {
|
||
state.match.myProgress = payload.progress;
|
||
updateMyArrows(payload.correct);
|
||
} else {
|
||
state.match.oppProgress = payload.progress;
|
||
updateOppArrows(payload.correct);
|
||
}
|
||
break;
|
||
|
||
case 'round-complete':
|
||
state.match.roundActive = false;
|
||
state.match.matchScores = payload.matchScores;
|
||
renderRoundResult(payload.winner);
|
||
break;
|
||
|
||
case 'match-end':
|
||
state.match.matchScores = payload.matchScores;
|
||
renderMatchEnd(payload.winner);
|
||
break;
|
||
|
||
case 'opponent-left':
|
||
showToast('Opponent left the match.');
|
||
setTimeout(() => showView('lobby'), 1800);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
||
async function loadDashboard() {
|
||
try {
|
||
const data = await api('GET', '/dashboard');
|
||
renderDashboard(data);
|
||
} catch {
|
||
/* silently ignore – dashboard is cosmetic */
|
||
}
|
||
}
|
||
|
||
function renderDashboard({ stats, rank, online, recent, daily }) {
|
||
setText('dash-total-score', stats.totalScore || 0);
|
||
setText('dash-rank', rank ? '#' + rank.position : 'Unranked');
|
||
setText('dash-sessions', stats.sessions || 0);
|
||
|
||
const wr = (stats.matches > 0) ? Math.round((stats.wins / stats.matches) * 100) + '%' : '—';
|
||
setText('dash-win-rate', wr);
|
||
|
||
if (daily) {
|
||
setText('dash-daily-name', daily.stratagem.name);
|
||
setText('dash-daily-category', daily.stratagem.category);
|
||
setText('dash-daily-best', daily.bestTime ? (daily.bestTime / 1000).toFixed(2) + 's' : 'No record yet');
|
||
// Store for the "Practice this" shortcut
|
||
state.practice.dailyTarget = daily.stratagem.name;
|
||
}
|
||
|
||
const tbody = document.getElementById('dash-recent');
|
||
if (recent.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="3" class="muted">No sessions yet</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = recent.map(r =>
|
||
`<tr><td>${esc(r.stratagem)}</td><td>${r.score}</td><td>${(r.time_ms / 1000).toFixed(2)}s</td></tr>`
|
||
).join('');
|
||
}
|
||
|
||
updateDashboardOnline(online);
|
||
}
|
||
|
||
function updateDashboardOnline(online) {
|
||
const el = document.getElementById('dash-online');
|
||
if (!el) return;
|
||
const others = (online || []).filter(u => u !== state.user?.user);
|
||
if (others.length === 0) {
|
||
el.innerHTML = '<span class="muted">No other Helldivers online</span>';
|
||
} else {
|
||
el.innerHTML = others.map(u =>
|
||
`<div class="online-user">
|
||
<span class="online-dot"></span>
|
||
<span style="flex:1;font-family:var(--font-mono)">${esc(u)}</span>
|
||
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(u)}">⚔ Challenge</button>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
}
|
||
|
||
function startDailyChallenge() {
|
||
if (!state.practice.dailyTarget) return;
|
||
state.practice.selectedCats.clear();
|
||
showView('practice');
|
||
// start a practice session focused on the daily stratagem
|
||
const strat = state.stratagems.find(s => s.name === state.practice.dailyTarget);
|
||
if (strat) {
|
||
state.practice.selectedCats.add(strat.category);
|
||
startPractice();
|
||
}
|
||
}
|
||
|
||
// ── Practice mode ─────────────────────────────────────────────────────────────
|
||
function initPracticeView() {
|
||
renderCategoryFilters();
|
||
if (!state.practice.active) showPracticeIdle();
|
||
}
|
||
|
||
function renderCategoryFilters() {
|
||
const cats = [...new Set(state.stratagems.map(s => s.category))];
|
||
const el = document.getElementById('practice-categories');
|
||
el.innerHTML = cats.map(cat => {
|
||
const active = state.practice.selectedCats.size === 0 || state.practice.selectedCats.has(cat);
|
||
return `<button class="cat-btn ${active ? 'active' : ''}"
|
||
data-action="toggle-cat" data-cat="${esc(cat)}">${esc(cat)}</button>`;
|
||
}).join('');
|
||
}
|
||
|
||
function toggleCategory(cat) {
|
||
if (state.practice.selectedCats.has(cat)) {
|
||
state.practice.selectedCats.delete(cat);
|
||
} else {
|
||
state.practice.selectedCats.add(cat);
|
||
}
|
||
renderCategoryFilters();
|
||
}
|
||
|
||
function showPracticeIdle() {
|
||
document.getElementById('practice-idle').classList.remove('hidden');
|
||
document.getElementById('practice-active').classList.add('hidden');
|
||
state.practice.active = false;
|
||
}
|
||
|
||
function startPractice() {
|
||
if (getPool().length === 0) { showToast('No stratagems match the selected filters'); return; }
|
||
state.practice.active = true;
|
||
state.practice.score = 0;
|
||
state.practice.streak = 0;
|
||
|
||
document.getElementById('practice-idle').classList.add('hidden');
|
||
document.getElementById('practice-active').classList.remove('hidden');
|
||
|
||
nextStratagem();
|
||
}
|
||
|
||
function stopPracticeUI() {
|
||
stopPracticeTimer();
|
||
showPracticeIdle();
|
||
}
|
||
|
||
function stopPracticeTimer() {
|
||
clearInterval(state.practice.timerHandle);
|
||
state.practice.timerHandle = null;
|
||
state.practice.active = false;
|
||
}
|
||
|
||
function getPool() {
|
||
const cats = state.practice.selectedCats;
|
||
if (cats.size === 0) return state.stratagems;
|
||
return state.stratagems.filter(s => cats.has(s.category));
|
||
}
|
||
|
||
function nextStratagem() {
|
||
const pool = getPool();
|
||
if (pool.length === 0) { showPracticeIdle(); return; }
|
||
state.practice.current = pool[Math.floor(Math.random() * pool.length)];
|
||
state.practice.progress = 0;
|
||
state.practice.timeLeft = 30;
|
||
state.practice.startTime = Date.now();
|
||
|
||
renderPracticeStratagem();
|
||
startPracticeTimer();
|
||
}
|
||
|
||
function startPracticeTimer() {
|
||
clearInterval(state.practice.timerHandle);
|
||
state.practice.timerHandle = setInterval(() => {
|
||
state.practice.timeLeft--;
|
||
updateTimerDisplay();
|
||
if (state.practice.timeLeft <= 0) {
|
||
clearInterval(state.practice.timerHandle);
|
||
state.practice.streak = 0;
|
||
updateStreakDisplay();
|
||
// Flash timer to signal timeout
|
||
const timerEl = document.getElementById('practice-timer');
|
||
timerEl.classList.add('flash-wrong');
|
||
setTimeout(() => { timerEl.classList.remove('flash-wrong'); nextStratagem(); }, 700);
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
function renderPracticeStratagem() {
|
||
const s = state.practice.current;
|
||
setText('practice-category', s.category);
|
||
setText('practice-name', s.name);
|
||
renderArrows('practice-sequence', s.sequence, state.practice.progress);
|
||
updateTimerDisplay();
|
||
updateScoreDisplay();
|
||
updateStreakDisplay();
|
||
}
|
||
|
||
function renderArrows(containerId, sequence, progress) {
|
||
const ARROW = { up: '↑', down: '↓', left: '←', right: '→' };
|
||
const el = document.getElementById(containerId);
|
||
if (!el) return;
|
||
el.innerHTML = sequence.map((dir, i) => {
|
||
let cls = 'arrow-key';
|
||
if (i < progress) cls += ' completed';
|
||
if (i === progress) cls += ' active';
|
||
return `<div class="${cls}">${ARROW[dir]}</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function updateTimerDisplay() {
|
||
const el = document.getElementById('practice-timer');
|
||
if (!el) return;
|
||
el.textContent = state.practice.timeLeft;
|
||
el.className = 'timer' + (state.practice.timeLeft <= 5 ? ' timer-danger' : '');
|
||
}
|
||
|
||
function updateScoreDisplay() { setText('practice-score', state.practice.score); }
|
||
function updateStreakDisplay() { setText('practice-streak', state.practice.streak); }
|
||
|
||
function handlePracticeInput(dir) {
|
||
const p = state.practice;
|
||
if (!p.active || !p.current) return;
|
||
|
||
const seq = p.current.sequence;
|
||
const arrows = document.querySelectorAll('#practice-sequence .arrow-key');
|
||
const curArrow = arrows[p.progress];
|
||
|
||
if (dir === seq[p.progress]) {
|
||
// Correct input
|
||
curArrow?.classList.add('flash-correct');
|
||
p.progress++;
|
||
|
||
if (p.progress === seq.length) {
|
||
// Stratagem completed!
|
||
clearInterval(p.timerHandle);
|
||
const elapsed = Date.now() - p.startTime;
|
||
const secs = Math.min(30, elapsed / 1000);
|
||
const pts = Math.round((100 + (30 - secs) * 3) * (1 + p.streak * 0.1));
|
||
|
||
p.score += pts;
|
||
p.streak++;
|
||
updateScoreDisplay();
|
||
updateStreakDisplay();
|
||
|
||
// Flash all arrows green
|
||
document.querySelectorAll('#practice-sequence .arrow-key').forEach(el => {
|
||
el.classList.remove('flash-correct');
|
||
el.classList.add('completed');
|
||
});
|
||
|
||
// Save result (fire-and-forget)
|
||
api('POST', '/scores/practice', {
|
||
stratagem: p.current.name,
|
||
category: p.current.category,
|
||
time_ms: elapsed,
|
||
score: pts,
|
||
}).catch(() => {});
|
||
|
||
setTimeout(nextStratagem, 600);
|
||
} else {
|
||
// Re-render with updated progress (highlights next arrow)
|
||
renderArrows('practice-sequence', seq, p.progress);
|
||
}
|
||
} else {
|
||
// Wrong input – reset progress
|
||
curArrow?.classList.add('flash-wrong');
|
||
p.progress = 0;
|
||
p.streak = 0;
|
||
updateStreakDisplay();
|
||
setTimeout(() => {
|
||
renderArrows('practice-sequence', seq, 0);
|
||
}, 350);
|
||
}
|
||
}
|
||
|
||
// ── Lobby ─────────────────────────────────────────────────────────────────────
|
||
function updateLobbyView() {
|
||
const others = state.lobby.online.filter(u => u !== state.user?.user);
|
||
const el = document.getElementById('lobby-players');
|
||
if (!el) return;
|
||
|
||
if (others.length === 0) {
|
||
el.innerHTML = '<p class="muted">No other Helldivers online. Waiting for reinforcements...</p>';
|
||
} else {
|
||
el.innerHTML = others.map(u =>
|
||
`<div class="lobby-player">
|
||
<span class="online-dot"></span>
|
||
<span class="player-name">${esc(u)}</span>
|
||
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(u)}">⚔ Challenge</button>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
// Incoming challenges
|
||
const challEl = document.getElementById('lobby-challenges');
|
||
if (!challEl) return;
|
||
const inc = state.lobby.incoming;
|
||
if (inc.length === 0) {
|
||
challEl.innerHTML = '';
|
||
} else {
|
||
challEl.innerHTML = inc.map(from =>
|
||
`<div class="challenge-item">
|
||
<span style="flex:1"><strong>${esc(from)}</strong> challenges you to a duel!</span>
|
||
<button class="btn btn-sm btn-accent" data-action="accept" data-user="${esc(from)}">Accept</button>
|
||
<button class="btn btn-sm btn-muted" data-action="decline" data-user="${esc(from)}">Decline</button>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
}
|
||
|
||
function sendChallenge(target) {
|
||
wsSend('challenge-user', { targetUser: target });
|
||
showToast('Challenge sent to ' + esc(target));
|
||
}
|
||
|
||
function acceptChallenge(from) {
|
||
wsSend('accept-challenge', { challengerId: from });
|
||
// Remove from incoming list
|
||
state.lobby.incoming = state.lobby.incoming.filter(u => u !== from);
|
||
updateChallengeBadge();
|
||
}
|
||
|
||
function declineChallenge(from) {
|
||
wsSend('decline-challenge', { challengerId: from });
|
||
state.lobby.incoming = state.lobby.incoming.filter(u => u !== from);
|
||
updateChallengeBadge();
|
||
if (state.currentView === 'lobby') updateLobbyView();
|
||
}
|
||
|
||
function updateChallengeBadge() {
|
||
const badge = document.getElementById('challenge-badge');
|
||
if (!badge) return;
|
||
const count = state.lobby.incoming.length;
|
||
if (count > 0) {
|
||
badge.textContent = count + ' Challenge' + (count > 1 ? 's' : '') + ' – Go to 1v1';
|
||
badge.classList.remove('hidden');
|
||
badge.onclick = () => showView('lobby');
|
||
} else {
|
||
badge.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// ── Match ─────────────────────────────────────────────────────────────────────
|
||
function renderMatchWaiting() {
|
||
const m = state.match;
|
||
setText('match-me-name', state.user.user);
|
||
setText('match-opp-name', m.opponent);
|
||
setText('match-status', 'Waiting for both players...');
|
||
setText('match-category', '');
|
||
renderMatchScores();
|
||
|
||
document.getElementById('match-round-area').classList.add('hidden');
|
||
|
||
const readyBtn = document.getElementById('match-ready-btn');
|
||
readyBtn.textContent = 'READY';
|
||
readyBtn.disabled = false;
|
||
readyBtn.classList.remove('hidden');
|
||
}
|
||
|
||
function renderMatchScores() {
|
||
const m = state.match;
|
||
setText('match-me-wins', m.matchScores[state.user.user] ?? 0);
|
||
setText('match-opp-wins', m.matchScores[m.opponent] ?? 0);
|
||
}
|
||
|
||
function setReady() {
|
||
wsSend('player-ready');
|
||
const btn = document.getElementById('match-ready-btn');
|
||
btn.textContent = 'Ready – waiting for opponent...';
|
||
btn.disabled = true;
|
||
}
|
||
|
||
function renderMatchRound() {
|
||
const m = state.match;
|
||
setText('match-status', m.current.name);
|
||
setText('match-category', m.current.category);
|
||
document.getElementById('match-round-area').classList.remove('hidden');
|
||
document.getElementById('match-ready-btn').classList.add('hidden');
|
||
|
||
renderArrows('match-me-sequence', m.current.sequence, 0);
|
||
renderArrows('match-opp-sequence', m.current.sequence, 0);
|
||
}
|
||
|
||
function updateMyArrows(correct) {
|
||
renderArrows('match-me-sequence', state.match.current.sequence, state.match.myProgress);
|
||
if (!correct) {
|
||
const el = document.getElementById('match-me-sequence');
|
||
el?.classList.add('flash-wrong-seq');
|
||
setTimeout(() => el?.classList.remove('flash-wrong-seq'), 350);
|
||
}
|
||
}
|
||
|
||
function updateOppArrows(correct) {
|
||
renderArrows('match-opp-sequence', state.match.current.sequence, state.match.oppProgress);
|
||
}
|
||
|
||
function handleMatchInput(dir) {
|
||
if (!state.match.roundActive) return;
|
||
wsSend('input-arrow', { direction: dir });
|
||
}
|
||
|
||
function renderRoundResult(winner) {
|
||
const won = winner === state.user.user;
|
||
setText('match-status', won ? '✓ ROUND WON' : '✗ ROUND LOST');
|
||
renderMatchScores();
|
||
|
||
// Short pause then show ready button for next round
|
||
setTimeout(() => {
|
||
document.getElementById('match-round-area').classList.add('hidden');
|
||
const btn = document.getElementById('match-ready-btn');
|
||
btn.textContent = 'Ready for next round';
|
||
btn.disabled = false;
|
||
btn.classList.remove('hidden');
|
||
setText('match-category', '');
|
||
}, 1600);
|
||
}
|
||
|
||
function renderMatchEnd(winner) {
|
||
const won = winner === state.user.user;
|
||
setText('match-status', won ? '🏆 MATCH WON!' : '☠ MATCH LOST');
|
||
renderMatchScores();
|
||
document.getElementById('match-round-area').classList.add('hidden');
|
||
document.getElementById('match-ready-btn').classList.add('hidden');
|
||
setTimeout(() => { if (state.currentView === 'match') showView('lobby'); }, 3000);
|
||
}
|
||
|
||
function leaveMatch() {
|
||
wsSend('leave-room');
|
||
showView('lobby');
|
||
}
|
||
|
||
// ── Leaderboard ───────────────────────────────────────────────────────────────
|
||
async function loadLeaderboard() {
|
||
const tbody = document.getElementById('leaderboard-table-body');
|
||
try {
|
||
const rows = await api('GET', '/scores/leaderboard');
|
||
if (rows.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="5" class="muted">No scores yet. Start practicing!</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = rows.map((r, i) =>
|
||
`<tr class="${r.username === state.user?.user ? 'row-me' : ''}">
|
||
<td class="rank">${i + 1}</td>
|
||
<td style="font-family:var(--font-mono)">${esc(r.username)}</td>
|
||
<td style="font-family:var(--font-mono)">${r.totalScore}</td>
|
||
<td style="font-family:var(--font-mono)">${r.sessions}</td>
|
||
<td style="font-family:var(--font-mono)">${r.wins}/${r.matches}</td>
|
||
</tr>`
|
||
).join('');
|
||
}
|
||
} catch {
|
||
tbody.innerHTML = '<tr><td colspan="5" class="muted">Error loading leaderboard</td></tr>';
|
||
}
|
||
}
|
||
|
||
// ── Admin panel ───────────────────────────────────────────────────────────────
|
||
async function loadAdmin() {
|
||
if (state.user?.role !== 'admin') { showView('dashboard'); return; }
|
||
try {
|
||
const users = await api('GET', '/users');
|
||
renderAdminUsers(users);
|
||
} catch {
|
||
document.getElementById('admin-users').innerHTML = '<span class="muted">Error loading users</span>';
|
||
}
|
||
}
|
||
|
||
function renderAdminUsers(users) {
|
||
const el = document.getElementById('admin-users');
|
||
el.innerHTML = users.map(u =>
|
||
`<div class="admin-user-row">
|
||
<span class="user-name">${esc(u.username)}</span>
|
||
<span class="user-role badge-${u.role}">${u.role}</span>
|
||
${u.mustChange ? '<span class="badge-warning">temp pw</span>' : ''}
|
||
${u.username !== state.user.user
|
||
? `<button class="btn btn-sm btn-danger" data-action="delete-user" data-user="${esc(u.username)}">Delete</button>`
|
||
: ''}
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
async function createUser() {
|
||
const username = document.getElementById('new-username').value.trim();
|
||
const role = document.getElementById('new-role').value;
|
||
const errEl = document.getElementById('admin-error');
|
||
const pwEl = document.getElementById('new-pw-display');
|
||
errEl.classList.add('hidden');
|
||
pwEl.classList.add('hidden');
|
||
|
||
if (!username) return;
|
||
try {
|
||
const result = await api('POST', '/users', { username, role });
|
||
pwEl.textContent = 'Temp password for ' + esc(username) + ': ' + esc(result.tempPassword);
|
||
pwEl.classList.remove('hidden');
|
||
document.getElementById('new-username').value = '';
|
||
loadAdmin();
|
||
} catch (err) {
|
||
errEl.textContent = err.message;
|
||
errEl.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
async function deleteUser(username) {
|
||
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
||
try {
|
||
await api('DELETE', '/users/' + encodeURIComponent(username));
|
||
loadAdmin();
|
||
} catch (err) {
|
||
showToast('Error: ' + err.message);
|
||
}
|
||
}
|
||
|
||
// ── Event delegation (replaces inline onclick for user-data actions) ──────────
|
||
document.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('[data-action]');
|
||
if (!btn) return;
|
||
|
||
const action = btn.dataset.action;
|
||
const user = btn.dataset.user;
|
||
const cat = btn.dataset.cat;
|
||
|
||
if (action === 'challenge' && user) sendChallenge(user);
|
||
if (action === 'accept' && user) acceptChallenge(user);
|
||
if (action === 'decline' && user) declineChallenge(user);
|
||
if (action === 'delete-user' && user) deleteUser(user);
|
||
if (action === 'toggle-cat' && cat) toggleCategory(cat);
|
||
});
|
||
|
||
// ── Keyboard input ────────────────────────────────────────────────────────────
|
||
document.addEventListener('keydown', (e) => {
|
||
const MAP = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
|
||
const dir = MAP[e.key];
|
||
if (!dir) return;
|
||
|
||
if (state.currentView === 'practice' || state.currentView === 'match') {
|
||
e.preventDefault(); // prevent page scroll
|
||
dpadInput(dir);
|
||
}
|
||
});
|
||
|
||
// Called by on-screen D-pad and keyboard handler
|
||
function dpadInput(dir) {
|
||
if (state.currentView === 'practice') handlePracticeInput(dir);
|
||
if (state.currentView === 'match') handleMatchInput(dir);
|
||
}
|
||
|
||
// ── Utils ─────────────────────────────────────────────────────────────────────
|
||
function esc(str) {
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function setText(id, value) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = value;
|
||
}
|
||
|
||
function showToast(msg) {
|
||
const container = document.getElementById('toast-container');
|
||
// Limit simultaneous toasts to avoid stacking
|
||
if (container.children.length >= 3) container.firstChild?.remove();
|
||
|
||
const toast = document.createElement('div');
|
||
toast.className = 'toast';
|
||
toast.textContent = msg;
|
||
container.appendChild(toast);
|
||
|
||
requestAnimationFrame(() => requestAnimationFrame(() => toast.classList.add('show')));
|
||
|
||
setTimeout(() => {
|
||
toast.classList.remove('show');
|
||
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
||
}, 3200);
|
||
}
|
||
|
||
// ── Static button bindings (replaces inline onclick – blocked by CSP script-src-attr) ──
|
||
document.getElementById('btn-logout') ?.addEventListener('click', logout);
|
||
document.getElementById('btn-daily-challenge') ?.addEventListener('click', startDailyChallenge);
|
||
document.getElementById('btn-start-practice') ?.addEventListener('click', startPractice);
|
||
document.getElementById('btn-stop-practice') ?.addEventListener('click', stopPracticeUI);
|
||
document.getElementById('match-ready-btn') ?.addEventListener('click', setReady);
|
||
document.getElementById('btn-leave-match') ?.addEventListener('click', leaveMatch);
|
||
document.getElementById('btn-create-user') ?.addEventListener('click', createUser);
|
||
|
||
// D-pad: practice and match both use data-dir buttons
|
||
document.getElementById('practice-dpad')?.addEventListener('click', (e) => {
|
||
const dir = e.target.closest('[data-dir]')?.dataset.dir;
|
||
if (dir) dpadInput(dir);
|
||
});
|
||
document.getElementById('match-dpad')?.addEventListener('click', (e) => {
|
||
const dir = e.target.closest('[data-dir]')?.dataset.dir;
|
||
if (dir) dpadInput(dir);
|
||
});
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||
document.addEventListener('DOMContentLoaded', checkAuth);
|