feat: polish gameplay and admin flow
This commit is contained in:
+268
-18
@@ -56,7 +56,7 @@ const state = {
|
||||
speedrunElapsed: 0,
|
||||
|
||||
// Session stats
|
||||
sessionStats: { completed: 0, missed: 0, bestTime: Infinity, stratagems: {} },
|
||||
sessionStats: { completed: 0, missed: 0, bestTime: Infinity, stratagems: {}, mistakes: {}, maxStreak: 0 },
|
||||
},
|
||||
|
||||
lobby: {
|
||||
@@ -144,6 +144,46 @@ function showView(name) {
|
||||
if (name === 'practice') initPracticeView();
|
||||
if (name === 'lobby') updateLobbyView();
|
||||
if (name === 'history') loadHistory();
|
||||
|
||||
if (name !== 'practice') document.body.classList.remove('in-practice-session');
|
||||
if (name !== 'match') document.body.classList.remove('in-match-round');
|
||||
}
|
||||
|
||||
function focusGameplayArea(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function runGameplayCountdown(label = 'Deploying', steps = ['3', '2', '1', 'GO']) {
|
||||
const el = document.getElementById('gameplay-countdown');
|
||||
if (!el) return;
|
||||
el.classList.remove('hidden');
|
||||
for (const step of steps) {
|
||||
el.innerHTML = `<span class="countdown-label">${esc(label)}</span><strong>${esc(step)}</strong>`;
|
||||
el.classList.remove('countdown-pop');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('countdown-pop');
|
||||
await sleep(step === 'GO' ? 380 : 520);
|
||||
}
|
||||
el.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showGameplayFeedback(id, text, tone = 'info', duration = 900) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.className = `gameplay-feedback gameplay-feedback-${tone}`;
|
||||
clearTimeout(el._hideTimer);
|
||||
el._hideTimer = setTimeout(() => {
|
||||
el.className = 'gameplay-feedback hidden';
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
@@ -239,6 +279,9 @@ document.getElementById('change-password-form').addEventListener('submit', async
|
||||
document.querySelectorAll('.nav-btn[data-view]').forEach(btn => {
|
||||
btn.addEventListener('click', () => showView(btn.dataset.view));
|
||||
});
|
||||
document.getElementById('btn-briefing-practice')?.addEventListener('click', () => showView('practice'));
|
||||
document.getElementById('btn-briefing-lobby')?.addEventListener('click', () => showView('lobby'));
|
||||
document.getElementById('btn-briefing-leaderboard')?.addEventListener('click', () => showView('leaderboard'));
|
||||
|
||||
// ── Hamburger nav ─────────────────────────────────────────────────────────────
|
||||
function openDrawer() {
|
||||
@@ -317,8 +360,9 @@ function handleWSMessage({ type, payload }) {
|
||||
state.match.current = payload.stratagem;
|
||||
state.match.myProgress = 0;
|
||||
state.match.oppProgress = 0;
|
||||
state.match.roundActive = true;
|
||||
state.match.roundActive = false;
|
||||
renderMatchRound();
|
||||
beginMatchRound();
|
||||
break;
|
||||
|
||||
case 'input-result':
|
||||
@@ -365,6 +409,7 @@ async function loadDashboard() {
|
||||
|
||||
function renderDashboard({ stats, rank, elo, eloRank: rankLabel, online, recent, daily }) {
|
||||
const myRank = eloRankFor(elo || 1000);
|
||||
const onlineOthers = (online || []).filter(u => u.name !== state.user?.user);
|
||||
setText('dash-hero-name', state.user.user);
|
||||
setText('dash-rank-label', rankLabel || myRank.label);
|
||||
setText('dash-elo', elo || 1000);
|
||||
@@ -375,14 +420,22 @@ function renderDashboard({ stats, rank, elo, eloRank: rankLabel, online, recent,
|
||||
setText('dash-sessions', stats.sessions || 0);
|
||||
const wr = stats.matches > 0 ? Math.round(((stats.wins || 0) / stats.matches) * 100) + '%' : '—';
|
||||
setText('dash-win-rate', wr);
|
||||
setText('dash-online-count', String(onlineOthers.length));
|
||||
|
||||
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');
|
||||
setText('dash-daily-focus', daily.stratagem.category);
|
||||
setText('dash-status-line', daily.bestTime
|
||||
? `Daily focus is ${daily.stratagem.name}. Your best run is ${(daily.bestTime / 1000).toFixed(2)}s, so a cleaner sequence could move you up fast.`
|
||||
: `Daily focus is ${daily.stratagem.name}. No record logged yet, so this is a clean chance to set the pace for today.`);
|
||||
state.practice.dailyTarget = daily.stratagem.name;
|
||||
renderDailySequencePreview(daily.stratagem.sequence);
|
||||
setIcon(document.getElementById('dash-daily-icon'), daily.stratagem.icon);
|
||||
} else {
|
||||
setText('dash-daily-focus', 'Stand By');
|
||||
setText('dash-status-line', 'Systems are online. Review recent runs, sharpen your execution, and push your rank before heading into the arena.');
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('dash-recent');
|
||||
@@ -469,6 +522,7 @@ function showPracticeIdle() {
|
||||
document.getElementById('hud-lives-wrap').classList.add('hidden');
|
||||
document.getElementById('hud-timer-wrap').classList.remove('hidden');
|
||||
document.getElementById('danger-vignette').classList.add('hidden');
|
||||
document.body.classList.remove('in-practice-session');
|
||||
state.practice.active = false;
|
||||
}
|
||||
|
||||
@@ -479,10 +533,10 @@ function getPool() {
|
||||
}
|
||||
|
||||
function resetSessionStats() {
|
||||
state.practice.sessionStats = { completed: 0, missed: 0, bestTime: Infinity, stratagems: {} };
|
||||
state.practice.sessionStats = { completed: 0, missed: 0, bestTime: Infinity, stratagems: {}, mistakes: {}, maxStreak: 0 };
|
||||
}
|
||||
|
||||
function startPractice() {
|
||||
async function startPractice() {
|
||||
const pool = getPool();
|
||||
if (!pool.length) { showToast('No stratagems match the selected filters'); return; }
|
||||
|
||||
@@ -496,6 +550,7 @@ function startPractice() {
|
||||
|
||||
document.getElementById('practice-idle').classList.add('hidden');
|
||||
document.getElementById('practice-active').classList.remove('hidden');
|
||||
document.body.classList.add('in-practice-session');
|
||||
|
||||
if (mode === 'drill') {
|
||||
state.practice.drillPool = shuffleArray([...pool]);
|
||||
@@ -526,6 +581,9 @@ function startPractice() {
|
||||
setText('hud-timer-label', 'TIME');
|
||||
}
|
||||
|
||||
focusGameplayArea('practice-active');
|
||||
showGameplayFeedback('practice-feedback', 'Stand by. Deployment starting.', 'info', 1500);
|
||||
await runGameplayCountdown('Deployment');
|
||||
nextStratagem();
|
||||
}
|
||||
|
||||
@@ -642,8 +700,10 @@ function startPracticeTimer() {
|
||||
document.getElementById('danger-vignette').classList.add('hidden');
|
||||
state.practice.streak = 0;
|
||||
state.practice.sessionStats.missed++;
|
||||
trackPracticeMistake();
|
||||
updateStreakDisplay();
|
||||
shakeIcon();
|
||||
showGameplayFeedback('practice-feedback', 'Timer expired. Next stratagem.', 'danger', 1000);
|
||||
setTimeout(nextStratagem, 500);
|
||||
}
|
||||
}, 1000);
|
||||
@@ -687,6 +747,7 @@ function renderPracticeStratagem() {
|
||||
if (iconEl) iconEl.style.display = 'none';
|
||||
if (fallbackEl) fallbackEl.style.display = '';
|
||||
}
|
||||
showGameplayFeedback('practice-feedback', `${s.name} ready. Execute the sequence.`, 'info', 900);
|
||||
}
|
||||
|
||||
function setIcon(imgEl, src) {
|
||||
@@ -698,17 +759,25 @@ function setIcon(imgEl, src) {
|
||||
}
|
||||
|
||||
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>`;
|
||||
return `<div class="${cls}">${renderDirGlyph(dir)}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderDirGlyph(dir) {
|
||||
return `<span class="dir-glyph dir-${esc(dir)}" aria-hidden="true">
|
||||
<svg viewBox="0 0 64 64" focusable="false">
|
||||
<path class="dir-line" d="M32 50 L32 18" />
|
||||
<path class="dir-head" d="M32 12 L20 24 H28 V50 H36 V24 H44 Z" />
|
||||
</svg>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function updateTimerDisplay(total) {
|
||||
if (state.practice.mode === 'speedrun') return;
|
||||
const el = document.getElementById('practice-timer');
|
||||
@@ -754,6 +823,13 @@ function updateLivesDisplay() {
|
||||
).join('');
|
||||
}
|
||||
|
||||
function trackPracticeMistake() {
|
||||
const current = state.practice.current;
|
||||
if (!current) return;
|
||||
const mistakes = state.practice.sessionStats.mistakes;
|
||||
mistakes[current.name] = (mistakes[current.name] || 0) + 1;
|
||||
}
|
||||
|
||||
function updateDrillProgress() {
|
||||
const p = state.practice;
|
||||
setText('drill-progress-text', p.drillCompleted + ' / ' + p.drillTotal);
|
||||
@@ -801,6 +877,7 @@ function handlePracticeInput(dir) {
|
||||
|
||||
p.score += pts;
|
||||
p.streak++;
|
||||
p.sessionStats.maxStreak = Math.max(p.sessionStats.maxStreak, p.streak);
|
||||
p.sessionStats.completed++;
|
||||
if (elapsed < p.sessionStats.bestTime) p.sessionStats.bestTime = elapsed;
|
||||
|
||||
@@ -825,6 +902,7 @@ function handlePracticeInput(dir) {
|
||||
|
||||
// Score popup
|
||||
showScorePopup('+' + pts);
|
||||
showGameplayFeedback('practice-feedback', `${p.streak >= 5 ? 'Perfect chain' : 'Confirmed'} +${pts}`, 'success', 1100);
|
||||
|
||||
api('POST', '/scores/practice', {
|
||||
stratagem: p.current.name,
|
||||
@@ -852,6 +930,8 @@ function handlePracticeInput(dir) {
|
||||
cur?.classList.add('flash-wrong');
|
||||
p.progress = 0;
|
||||
shakeIcon();
|
||||
trackPracticeMistake();
|
||||
showGameplayFeedback('practice-feedback', 'Wrong input. Sequence reset.', 'danger', 1000);
|
||||
|
||||
if (mode === 'endless') {
|
||||
p.lives--;
|
||||
@@ -931,7 +1011,7 @@ function openSessionSummary() {
|
||||
grid.innerHTML = [
|
||||
{ label: 'Score', val: p.score },
|
||||
{ label: 'Completed', val: s.completed },
|
||||
{ label: 'Streak Max',val: p.streak },
|
||||
{ label: 'Streak Max',val: s.maxStreak },
|
||||
{ label: 'Accuracy', val: accuracy },
|
||||
{ label: 'Best Time', val: bestTimeStr },
|
||||
{ label: 'Mode', val: p.mode },
|
||||
@@ -941,6 +1021,20 @@ function openSessionSummary() {
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
const insightsEl = document.getElementById('summary-insights');
|
||||
if (insightsEl) {
|
||||
const mistakeEntries = Object.entries(s.mistakes).sort((a, b) => b[1] - a[1]);
|
||||
const stratEntries = Object.entries(s.stratagems)
|
||||
.map(([name, stat]) => ({ name, avg: stat.totalMs / stat.count, count: stat.count }))
|
||||
.sort((a, b) => b.avg - a.avg);
|
||||
const insights = [
|
||||
`Accuracy landed at ${accuracy}${s.maxStreak >= 5 ? ` with a peak streak of ${s.maxStreak}.` : '.'}`,
|
||||
stratEntries[0] ? `Slowest repeated stratagem was ${stratEntries[0].name} at ${(stratEntries[0].avg / 1000).toFixed(2)}s average.` : 'Run more sessions to identify your slowest stratagems.',
|
||||
mistakeEntries[0] ? `Most common reset came from ${mistakeEntries[0][0]} with ${mistakeEntries[0][1]} mistake${mistakeEntries[0][1] > 1 ? 's' : ''}.` : 'No input resets recorded in this session.',
|
||||
];
|
||||
insightsEl.innerHTML = insights.map(line => `<div class="summary-insight">${esc(line)}</div>`).join('');
|
||||
}
|
||||
|
||||
// Top stratagems by count
|
||||
const topEl = document.getElementById('summary-top-stratagems');
|
||||
if (topEl) {
|
||||
@@ -1087,6 +1181,9 @@ function renderMatchWaiting() {
|
||||
btn.textContent = 'READY';
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('hidden');
|
||||
document.body.classList.remove('in-match-round');
|
||||
showGameplayFeedback('match-feedback', 'Waiting for both divers to ready up.', 'info', 1200);
|
||||
updateMatchProgressUI();
|
||||
// Hide match icon
|
||||
const matchIcon = document.getElementById('match-icon');
|
||||
if (matchIcon) matchIcon.style.display = 'none';
|
||||
@@ -1111,27 +1208,35 @@ function renderMatchRound() {
|
||||
setText('match-category', m.current.category);
|
||||
document.getElementById('match-round-area').classList.remove('hidden');
|
||||
document.getElementById('match-ready-btn').classList.add('hidden');
|
||||
document.body.classList.add('in-match-round');
|
||||
renderArrows('match-me-sequence', m.current.sequence, 0);
|
||||
renderArrows('match-opp-sequence', m.current.sequence, 0);
|
||||
updateMatchProgressUI();
|
||||
|
||||
// Show stratagem icon in match
|
||||
const strat = state.stratagems.find(s => s.name === m.current.name);
|
||||
const matchIcon = document.getElementById('match-icon');
|
||||
if (strat?.icon) setIcon(matchIcon, strat.icon);
|
||||
else if (matchIcon) matchIcon.style.display = 'none';
|
||||
focusGameplayArea('match-round-area');
|
||||
}
|
||||
|
||||
function updateMyArrows(correct) {
|
||||
renderArrows('match-me-sequence', state.match.current.sequence, state.match.myProgress);
|
||||
updateMatchProgressUI();
|
||||
if (!correct) {
|
||||
const el = document.getElementById('match-me-sequence');
|
||||
el?.classList.add('flash-wrong-seq');
|
||||
setTimeout(() => el?.classList.remove('flash-wrong-seq'), 350);
|
||||
showGameplayFeedback('match-feedback', 'Input rejected. Recover now.', 'danger', 900);
|
||||
} else {
|
||||
showGameplayFeedback('match-feedback', 'Confirmed. Keep pushing.', 'success', 550);
|
||||
}
|
||||
}
|
||||
|
||||
function updateOppArrows() {
|
||||
renderArrows('match-opp-sequence', state.match.current.sequence, state.match.oppProgress);
|
||||
updateMatchProgressUI();
|
||||
}
|
||||
|
||||
function handleMatchInput(dir) {
|
||||
@@ -1139,14 +1244,36 @@ function handleMatchInput(dir) {
|
||||
wsSend('input-arrow', { direction: dir });
|
||||
}
|
||||
|
||||
function updateMatchProgressUI() {
|
||||
const total = state.match.current?.sequence?.length || 0;
|
||||
const myFill = document.getElementById('match-me-progress-fill');
|
||||
const oppFill = document.getElementById('match-opp-progress-fill');
|
||||
const myText = document.getElementById('match-me-progress-text');
|
||||
const oppText = document.getElementById('match-opp-progress-text');
|
||||
if (myFill) myFill.style.width = total ? `${(state.match.myProgress / total) * 100}%` : '0%';
|
||||
if (oppFill) oppFill.style.width = total ? `${(state.match.oppProgress / total) * 100}%` : '0%';
|
||||
if (myText) myText.textContent = `${state.match.myProgress} / ${total}`;
|
||||
if (oppText) oppText.textContent = `${state.match.oppProgress} / ${total}`;
|
||||
}
|
||||
|
||||
async function beginMatchRound() {
|
||||
focusGameplayArea('match-round-area');
|
||||
showGameplayFeedback('match-feedback', 'Round locked. Prepare to input.', 'info', 1200);
|
||||
await runGameplayCountdown('Round Start');
|
||||
state.match.roundActive = true;
|
||||
showGameplayFeedback('match-feedback', 'Go go go.', 'success', 700);
|
||||
}
|
||||
|
||||
function renderRoundResult(winner) {
|
||||
const won = winner === state.user.user;
|
||||
setText('match-status', won ? '✓ ROUND WON' : '✗ ROUND LOST');
|
||||
renderMatchScores();
|
||||
showGameplayFeedback('match-feedback', won ? 'Round secured.' : 'Opponent took the round.', won ? 'success' : 'danger', 1200);
|
||||
const matchIcon = document.getElementById('match-icon');
|
||||
if (matchIcon) matchIcon.style.display = 'none';
|
||||
setTimeout(() => {
|
||||
document.getElementById('match-round-area').classList.add('hidden');
|
||||
document.body.classList.remove('in-match-round');
|
||||
const btn = document.getElementById('match-ready-btn');
|
||||
btn.textContent = 'Ready for next round';
|
||||
btn.disabled = false;
|
||||
@@ -1388,6 +1515,9 @@ document.getElementById('history-pagination')?.addEventListener('click', (e) =>
|
||||
|
||||
document.getElementById('history-filter-mode')?.addEventListener('change', () => { state.history.page = 1; loadHistory(); });
|
||||
document.getElementById('history-filter-cat')?.addEventListener('change', () => { state.history.page = 1; loadHistory(); });
|
||||
document.getElementById('admin-user-search')?.addEventListener('input', () => {
|
||||
renderAdminUsers(state.adminUsers || []);
|
||||
});
|
||||
|
||||
async function loadStratagemStats() {
|
||||
try {
|
||||
@@ -1416,25 +1546,143 @@ async function loadStratagemStats() {
|
||||
async function loadAdmin() {
|
||||
if (state.user?.role !== 'admin') { showView('dashboard'); return; }
|
||||
try {
|
||||
const users = await api('GET', '/users');
|
||||
const [users, overview, activity] = await Promise.all([
|
||||
api('GET', '/users'),
|
||||
api('GET', '/admin/overview'),
|
||||
api('GET', '/admin/activity'),
|
||||
]);
|
||||
state.adminUsers = users;
|
||||
renderAdminOverview(overview);
|
||||
renderAdminUsers(users);
|
||||
renderAdminActivity(activity);
|
||||
} catch {
|
||||
document.getElementById('admin-users').innerHTML = '<span class="muted">Error loading users</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAdminOverview(data = {}) {
|
||||
setText('admin-total-users', String(data.totals?.users ?? 0));
|
||||
setText('admin-total-admins', String(data.totals?.admins ?? 0));
|
||||
setText('admin-temp-passwords', String(data.totals?.tempPasswords ?? 0));
|
||||
setText('admin-practice-sessions', String(data.activity?.practiceSessions ?? 0));
|
||||
|
||||
const topUser = data.topUser?.username || 'No data yet';
|
||||
const topMeta = data.topUser
|
||||
? `Score ${Number(data.topUser.totalScore || 0).toLocaleString()} across ${data.topUser.sessions || 0} sessions`
|
||||
: 'Waiting for enough runs to identify a standout Helldiver.';
|
||||
|
||||
setText('admin-top-user', topUser);
|
||||
setText('admin-top-user-meta', topMeta);
|
||||
}
|
||||
|
||||
function renderAdminUsers(users) {
|
||||
const el = document.getElementById('admin-users');
|
||||
el.innerHTML = users.map(u =>
|
||||
`<div class="admin-user-row">
|
||||
<span style="font-family:var(--font-mono);flex:1">${esc(u.username)}</span>
|
||||
<span class="badge badge-${u.role}">${u.role}</span>
|
||||
${u.mustChange ? '<span class="badge 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('');
|
||||
const search = document.getElementById('admin-user-search')?.value.trim().toLowerCase() || '';
|
||||
const filtered = users.filter((u) => {
|
||||
if (!search) return true;
|
||||
return [
|
||||
u.username,
|
||||
u.role,
|
||||
String(u.elo ?? ''),
|
||||
String(u.sessions ?? ''),
|
||||
].join(' ').toLowerCase().includes(search);
|
||||
});
|
||||
|
||||
if (!filtered.length) {
|
||||
el.innerHTML = '<div class="admin-empty">No matching users found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = filtered.map((u) => {
|
||||
const isSelf = u.username === state.user.user;
|
||||
const nextRole = u.role === 'admin' ? 'user' : 'admin';
|
||||
const lastPlayed = u.lastPlayed ? new Date(u.lastPlayed).toLocaleString() : 'No activity yet';
|
||||
return `<div class="admin-user-row">
|
||||
<div class="admin-user-main">
|
||||
<div class="admin-user-name-row">
|
||||
<span class="user-name">${esc(u.username)}</span>
|
||||
<span class="badge badge-${u.role}">${u.role}</span>
|
||||
${u.mustChange ? '<span class="badge badge-warning">temp pw</span>' : ''}
|
||||
${isSelf ? '<span class="badge">you</span>' : ''}
|
||||
</div>
|
||||
<div class="admin-user-meta">
|
||||
<span>ELO ${Number(u.elo ?? 1000)}</span>
|
||||
<span>${Number(u.sessions ?? 0)} sessions</span>
|
||||
<span>${esc(lastPlayed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<button class="btn btn-muted btn-sm" data-action="reset-password" data-user="${esc(u.username)}">Reset password</button>
|
||||
${isSelf ? '' : `<button class="btn btn-sm" data-action="toggle-role" data-user="${esc(u.username)}" data-role="${nextRole}">${u.role === 'admin' ? 'Make user' : 'Make admin'}</button>`}
|
||||
${isSelf ? '' : `<button class="btn btn-sm btn-danger" data-action="delete-user" data-user="${esc(u.username)}">Delete</button>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderAdminActivity(data = {}) {
|
||||
const practiceEl = document.getElementById('admin-recent-practice');
|
||||
const matchesEl = document.getElementById('admin-recent-matches');
|
||||
|
||||
const practiceRows = data.practice || [];
|
||||
const matchRows = data.matches || [];
|
||||
|
||||
practiceEl.innerHTML = practiceRows.length
|
||||
? practiceRows.map((row) => `
|
||||
<div class="admin-activity-item">
|
||||
<div class="admin-activity-head">
|
||||
<strong>${esc(row.username)}</strong>
|
||||
<span>${esc(row.mode || 'practice')}</span>
|
||||
</div>
|
||||
<div class="admin-activity-body">${esc(row.stratagem || 'Unknown stratagem')}</div>
|
||||
<div class="admin-activity-meta">
|
||||
<span>${Number(row.score || 0)} pts</span>
|
||||
<span>${row.created_at ? esc(new Date(row.created_at).toLocaleString()) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<div class="admin-empty">No recent practice activity.</div>';
|
||||
|
||||
matchesEl.innerHTML = matchRows.length
|
||||
? matchRows.map((row) => `
|
||||
<div class="admin-activity-item">
|
||||
<div class="admin-activity-head">
|
||||
<strong>${esc(row.winner || 'Pending')}</strong>
|
||||
<span>${esc(row.winner || 'Pending')} vs ${esc(row.loser || 'Unknown')}</span>
|
||||
</div>
|
||||
<div class="admin-activity-body">Scoreline: ${row.winner_rounds ?? 0} : ${row.loser_rounds ?? 0}</div>
|
||||
<div class="admin-activity-meta">
|
||||
<span>${row.created_at ? esc(new Date(row.created_at).toLocaleString()) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<div class="admin-empty">No recent match activity.</div>';
|
||||
}
|
||||
|
||||
async function resetUserPassword(username) {
|
||||
if (!confirm(`Reset password for "${username}" and require a password change on next login?`)) return;
|
||||
try {
|
||||
const result = await api('POST', `/users/${encodeURIComponent(username)}/reset-password`);
|
||||
const pwEl = document.getElementById('new-pw-display');
|
||||
pwEl.textContent = `Temp password for ${username}: ${result.tempPassword}`;
|
||||
pwEl.classList.remove('hidden');
|
||||
showToast(`Password reset for ${username}`);
|
||||
loadAdmin();
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUserRole(username, role) {
|
||||
const label = role === 'admin' ? 'promote' : 'demote';
|
||||
if (!confirm(`Really ${label} "${username}"?`)) return;
|
||||
try {
|
||||
await api('PATCH', `/users/${encodeURIComponent(username)}`, { role });
|
||||
showToast(`Role updated for ${username}`);
|
||||
loadAdmin();
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
@@ -1478,6 +1726,8 @@ document.addEventListener('click', (e) => {
|
||||
if (action === 'challenge' && user) sendChallenge(user);
|
||||
if (action === 'accept' && user) acceptChallenge(user);
|
||||
if (action === 'decline' && user) declineChallenge(user);
|
||||
if (action === 'reset-password' && user) resetUserPassword(user);
|
||||
if (action === 'toggle-role' && user) updateUserRole(user, btn.dataset.role);
|
||||
if (action === 'delete-user' && user) deleteUser(user);
|
||||
if (action === 'toggle-cat' && cat) toggleCategory(cat);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user