fix: XSS event delegation, match btn class, CSS height/mobile, server validation
This commit is contained in:
+38
-18
@@ -298,7 +298,7 @@ function updateDashboardOnline(online) {
|
||||
`<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" onclick="sendChallenge('${esc(u)}')">⚔ Challenge</button>
|
||||
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(u)}">⚔ Challenge</button>
|
||||
</div>`
|
||||
).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 `<button class="cat-btn ${active ? 'active' : ''}"
|
||||
onclick="toggleCategory('${esc(cat)}')">${esc(cat)}</button>`;
|
||||
data-action="toggle-cat" data-cat="${esc(cat)}">${esc(cat)}</button>`;
|
||||
}).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() {
|
||||
`<div class="lobby-player">
|
||||
<span class="online-dot"></span>
|
||||
<span class="player-name">${esc(u)}</span>
|
||||
<button class="btn btn-sm btn-accent" onclick="sendChallenge('${esc(u)}')">⚔ Challenge</button>
|
||||
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(u)}">⚔ Challenge</button>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
@@ -520,8 +521,8 @@ function updateLobbyView() {
|
||||
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" onclick="acceptChallenge('${esc(from)}')">Accept</button>
|
||||
<button class="btn btn-sm btn-muted" onclick="declineChallenge('${esc(from)}')">Decline</button>
|
||||
<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('');
|
||||
}
|
||||
@@ -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) {
|
||||
<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" onclick="deleteUser('${esc(u.username)}')">Delete</button>`
|
||||
? `<button class="btn btn-sm btn-danger" data-action="delete-user" data-user="${esc(u.username)}">Delete</button>`
|
||||
: ''}
|
||||
</div>`
|
||||
).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);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -273,7 +273,7 @@
|
||||
</div>
|
||||
|
||||
<div class="match-actions">
|
||||
<button class="btn btn-accent" id="match-ready-btn" onclick="setReady()" style="display:none">READY</button>
|
||||
<button class="btn btn-accent hidden" id="match-ready-btn" onclick="setReady()">READY</button>
|
||||
<button class="btn btn-muted btn-sm" onclick="leaveMatch()">Leave Match</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+10
-2
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user