diff --git a/public/app.js b/public/app.js
index 9917adc..5e6ec3b 100644
--- a/public/app.js
+++ b/public/app.js
@@ -298,7 +298,7 @@ function updateDashboardOnline(online) {
`
${esc(u)}
-
+
`
).join('');
}
@@ -328,7 +328,7 @@ function renderCategoryFilters() {
el.innerHTML = cats.map(cat => {
const active = state.practice.selectedCats.size === 0 || state.practice.selectedCats.has(cat);
return ``;
+ data-action="toggle-cat" data-cat="${esc(cat)}">${esc(cat)}`;
}).join('');
}
@@ -378,6 +378,7 @@ function getPool() {
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;
@@ -505,7 +506,7 @@ function updateLobbyView() {
`
${esc(u)}
-
+
`
).join('');
}
@@ -520,8 +521,8 @@ function updateLobbyView() {
challEl.innerHTML = inc.map(from =>
`
${esc(from)} challenges you to a duel!
-
-
+
+
`
).join('');
}
@@ -573,7 +574,7 @@ function renderMatchWaiting() {
const readyBtn = document.getElementById('match-ready-btn');
readyBtn.textContent = 'READY';
readyBtn.disabled = false;
- readyBtn.style.display = 'inline-flex';
+ readyBtn.classList.remove('hidden');
}
function renderMatchScores() {
@@ -594,7 +595,7 @@ function renderMatchRound() {
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').style.display = 'none';
+ document.getElementById('match-ready-btn').classList.add('hidden');
renderArrows('match-me-sequence', m.current.sequence, 0);
renderArrows('match-opp-sequence', m.current.sequence, 0);
@@ -629,7 +630,7 @@ function renderRoundResult(winner) {
const btn = document.getElementById('match-ready-btn');
btn.textContent = 'Ready for next round';
btn.disabled = false;
- btn.style.display = 'inline-flex';
+ btn.classList.remove('hidden');
setText('match-category', '');
}, 1600);
}
@@ -639,8 +640,8 @@ function renderMatchEnd(winner) {
setText('match-status', won ? '🏆 MATCH WON!' : '☠ MATCH LOST');
renderMatchScores();
document.getElementById('match-round-area').classList.add('hidden');
- document.getElementById('match-ready-btn').style.display = 'none';
- setTimeout(() => showView('lobby'), 3000);
+ document.getElementById('match-ready-btn').classList.add('hidden');
+ setTimeout(() => { if (state.currentView === 'match') showView('lobby'); }, 3000);
}
function leaveMatch() {
@@ -690,7 +691,7 @@ function renderAdminUsers(users) {
${u.role}
${u.mustChange ? 'temp pw' : ''}
${u.username !== state.user.user
- ? ``
+ ? ``
: ''}
`
).join('');
@@ -718,7 +719,7 @@ async function createUser() {
}
async function deleteUser(username) {
- if (!confirm('Delete "' + username + '"? This cannot be undone.')) return;
+ if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
try {
await api('DELETE', '/users/' + encodeURIComponent(username));
loadAdmin();
@@ -727,6 +728,22 @@ async function deleteUser(username) {
}
}
+// ── 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' };
@@ -762,16 +779,19 @@ function setText(id, value) {
function showToast(msg) {
const container = document.getElementById('toast-container');
- const toast = document.createElement('div');
- toast.className = 'toast';
+ // 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'));
- });
+
+ requestAnimationFrame(() => requestAnimationFrame(() => toast.classList.add('show')));
+
setTimeout(() => {
toast.classList.remove('show');
- setTimeout(() => toast.remove(), 300);
+ toast.addEventListener('transitionend', () => toast.remove(), { once: true });
}, 3200);
}
diff --git a/public/index.html b/public/index.html
index 512324e..db7184c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -273,7 +273,7 @@
-
+
diff --git a/public/styles.css b/public/styles.css
index bf418b6..a9b4d52 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -71,7 +71,7 @@ body::after {
.view {
position: relative;
z-index: 1;
- min-height: calc(100vh - 64px);
+ min-height: calc(100vh - 56px);
padding: 24px 20px 48px;
max-width: 1100px;
margin: 0 auto;
@@ -570,6 +570,9 @@ select option { background: var(--bg-surface2); }
.timer.flash-wrong { animation: shake 0.4s ease; }
+/* Container shake when wrong input in match mode */
+.flash-wrong-seq { animation: shake 0.35s ease; }
+
/* ── D-Pad ─────────────────────────────────────────────────────────────────── */
.dpad {
display: flex;
@@ -827,7 +830,12 @@ select option { background: var(--bg-surface2); }
.nav-links { display: none; }
.dashboard-grid { grid-template-columns: 1fr; }
.admin-layout { grid-template-columns: 1fr; }
- .match-sequences { grid-template-columns: 1fr; }
+ .match-sequences { grid-template-columns: 1fr; gap: 12px; }
.stratagem-name { font-size: 1.4rem; }
.arrow-key { width: 40px; height: 40px; font-size: 1.1rem; }
+ .match-scoreboard { gap: 16px; }
+ .match-wins { font-size: 2rem; }
+ .match-status-text { font-size: 1.3rem; }
+ .practice-hud { gap: 16px; }
+ .timer { font-size: 2rem; }
}
diff --git a/server.js b/server.js
index 2f870c0..3f9e973 100644
--- a/server.js
+++ b/server.js
@@ -253,6 +253,7 @@ app.post('/api/change-password', requireAuth, async (req, res) => {
if (newPassword.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(req.session.user);
+ if (!user) return res.status(404).json({ error: 'User not found' });
const valid = await bcrypt.compare(oldPassword, user.hash);
if (!valid) return res.status(401).json({ error: 'Current password incorrect' });
@@ -380,7 +381,7 @@ app.get('/api/dashboard', requireAuth, (req, res) => {
app.post('/api/scores/practice', requireAuth, (req, res) => {
const { stratagem, category, time_ms, score } = req.body || {};
if (!VALID_NAMES.has(stratagem)) return res.status(400).json({ error: 'Invalid stratagem' });
- if (typeof time_ms !== 'number' || time_ms <= 0 || time_ms > 35_000) return res.status(400).json({ error: 'Invalid time' });
+ if (typeof time_ms !== 'number' || time_ms <= 0 || time_ms > 31_000) return res.status(400).json({ error: 'Invalid time' });
if (typeof score !== 'number' || score < 0 || score > 15_000) return res.status(400).json({ error: 'Invalid score' });
db.prepare(`