From f5f57c3e4db89d98a58285e0cf62965103f4fdd8 Mon Sep 17 00:00:00 2001 From: Jeremy Brandenburger Date: Fri, 3 Apr 2026 11:59:24 +0200 Subject: [PATCH] feat: polish gameplay and admin flow --- CHANGELOG.md | 3 + PROJECT_MAP.md | 4 + public/app.js | 286 ++++++- public/icons/airburst_rocket_launcher.svg | 16 +- public/icons/anti_materiel_rifle.svg | 16 +- public/icons/anti_personnel_minefield.svg | 20 +- public/icons/anti_tank_emplacement.svg | 17 +- public/icons/anti_tank_mines.svg | 16 +- public/icons/arc_thrower.svg | 19 +- public/icons/autocannon.svg | 19 +- public/icons/autocannon_sentry.svg | 21 +- public/icons/ballistic_shield_backpack.svg | 23 +- public/icons/commando.svg | 22 +- public/icons/directional_shield.svg | 16 +- public/icons/eagle_110mm_rocket_pods.svg | 24 +- public/icons/eagle_500kg_bomb.svg | 22 +- public/icons/eagle_airstrike.svg | 22 +- public/icons/eagle_cluster_bomb.svg | 25 +- public/icons/eagle_napalm_airstrike.svg | 22 +- public/icons/eagle_rearm.svg | 21 +- public/icons/eagle_smoke_strike.svg | 22 +- public/icons/eagle_strafing_run.svg | 36 +- public/icons/emancipator_exosuit.svg | 16 +- public/icons/ems_mortar_sentry.svg | 20 +- public/icons/expendable_anti_tank.svg | 19 +- public/icons/flamethrower.svg | 19 +- public/icons/gatling_sentry.svg | 27 +- public/icons/grenade_launcher.svg | 16 +- public/icons/guard_dog.svg | 23 +- public/icons/guard_dog_rover.svg | 23 +- public/icons/heavy_machine_gun.svg | 19 +- public/icons/hellbomb.svg | 20 +- public/icons/hmg_emplacement.svg | 22 +- public/icons/incendiary_mines.svg | 20 +- public/icons/laser_cannon.svg | 19 +- public/icons/lift_850_jump_pack.svg | 21 +- public/icons/machine_gun.svg | 19 +- public/icons/machine_gun_sentry.svg | 21 +- public/icons/mortar_sentry.svg | 20 +- public/icons/orbital_120mm_he_barrage.svg | 21 +- public/icons/orbital_380mm_he_barrage.svg | 23 +- public/icons/orbital_airburst_strike.svg | 32 +- public/icons/orbital_ems_strike.svg | 20 +- public/icons/orbital_gas_strike.svg | 19 +- public/icons/orbital_gatling_barrage.svg | 28 +- public/icons/orbital_illumination_flare.svg | 29 +- public/icons/orbital_laser.svg | 23 +- public/icons/orbital_precision_strike.svg | 20 +- public/icons/orbital_railcannon_strike.svg | 22 +- public/icons/orbital_smoke_strike.svg | 19 +- public/icons/orbital_walking_barrage.svg | 23 +- public/icons/patriot_exosuit.svg | 25 +- public/icons/prospecting_drill.svg | 18 +- public/icons/quasar_cannon.svg | 23 +- public/icons/railgun.svg | 22 +- public/icons/recoilless_rifle.svg | 19 +- public/icons/reinforce.svg | 18 +- public/icons/resupply.svg | 20 +- public/icons/rocket_sentry.svg | 20 +- public/icons/seaf_artillery.svg | 19 +- public/icons/shield_generator_pack.svg | 23 +- public/icons/shield_generator_relay.svg | 19 +- public/icons/sos_beacon.svg | 18 +- public/icons/spear.svg | 19 +- public/icons/stalwart.svg | 16 +- public/icons/supply_pack.svg | 23 +- public/icons/tesla_tower.svg | 20 +- public/icons/upload_data.svg | 18 +- public/styles.css | 886 +++++++++++++++++--- scripts/download-icons-wiki.js | 135 +++ scripts/download-icons.js | 6 - server.js | 106 ++- 72 files changed, 2286 insertions(+), 502 deletions(-) create mode 100644 scripts/download-icons-wiki.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f18a884..d9c251d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Changed - Release-Automation ergänzt: `npm run release:sync`, `npm run release:verify`, `.githooks/pre-commit` und versionsbasiertes Cache-Busting aus `package.json` – helldivers-trainer +- Practice- und Match-Flow wirken jetzt deutlich spielerischer mit Deployment-Countdown, Fokusbereich, direkter Feedback-Anzeige und staerkeren Mobile-HUDs +- Stratagem-Icons wurden breit ueberarbeitet und koennen ueber das neue Wiki-Download-Skript einfacher aktualisiert werden +- Admin-Bereich zeigt mehr Nutzer- und Aktivitaetsdaten, inklusive Sessions, letzter Aktivitaet, Passwort-Reset und Rollenpflege ## [2.1.3] – 2026-03-31 diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 5183e97..a74b002 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -48,6 +48,9 @@ state = { | `loadSettings() / saveSettings()` | localStorage-Persistenz für Settings | | `applySettingsToUI()` | Settings auf UI-Elemente anwenden | | `showView(name)` | Ansicht wechseln (login/dashboard/practice/leaderboard/history/admin) | +| `focusGameplayArea(id)` | Scrollt den aktiven Gameplay-Bereich gezielt in den Viewport | +| `runGameplayCountdown(label, steps)` | Kurzer Start-Countdown vor Practice- und Match-Runden | +| `showGameplayFeedback(id, text, tone, duration)` | Inline-Feedback fuer Treffer, Fehler und Startphasen | | `connectWS() / wsSend(type, payload)` | WebSocket-Verbindung + Nachrichten senden | | `handleWSMessage({ type, payload })` | Eingehende WS-Nachrichten dispatchen | @@ -63,6 +66,7 @@ state = { | `startSpeedrunTimer()` | Timer für Speedrun-Modus | | `renderPracticeStratagem()` | Aktuelles Stratagem + Pfeile rendern | | `renderArrows(containerId, sequence, progress)` | Pfeil-Sequenz rendern | +| `trackPracticeMistake()` | Fehler je Stratagem fuer die Session-Statistik erfassen | | `updateScoreDisplay() / updateStreakDisplay() / updateLivesDisplay()` | Score-UI | | `openSessionSummary() / closeSessionSummary()` | Sitzungs-Zusammenfassung Modal | diff --git a/public/app.js b/public/app.js index f73d037..a04a7ef 100644 --- a/public/app.js +++ b/public/app.js @@ -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 = `${esc(label)}${esc(step)}`; + 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 `
${ARROW[dir]}
`; + return `
${renderDirGlyph(dir)}
`; }).join(''); } +function renderDirGlyph(dir) { + return ``; +} + 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() { `).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 => `
${esc(line)}
`).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 = 'Error loading users'; } } +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 => - `
- ${esc(u.username)} - ${u.role} - ${u.mustChange ? 'temp pw' : ''} - ${u.username !== state.user.user - ? `` - : ''} -
` - ).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 = '
No matching users found.
'; + 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 `
+
+
+ ${esc(u.username)} + ${u.role} + ${u.mustChange ? 'temp pw' : ''} + ${isSelf ? 'you' : ''} +
+
+ ELO ${Number(u.elo ?? 1000)} + ${Number(u.sessions ?? 0)} sessions + ${esc(lastPlayed)} +
+
+ +
`; + }).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) => ` +
+
+ ${esc(row.username)} + ${esc(row.mode || 'practice')} +
+
${esc(row.stratagem || 'Unknown stratagem')}
+
+ ${Number(row.score || 0)} pts + ${row.created_at ? esc(new Date(row.created_at).toLocaleString()) : '—'} +
+
+ `).join('') + : '
No recent practice activity.
'; + + matchesEl.innerHTML = matchRows.length + ? matchRows.map((row) => ` +
+
+ ${esc(row.winner || 'Pending')} + ${esc(row.winner || 'Pending')} vs ${esc(row.loser || 'Unknown')} +
+
Scoreline: ${row.winner_rounds ?? 0} : ${row.loser_rounds ?? 0}
+
+ ${row.created_at ? esc(new Date(row.created_at).toLocaleString()) : '—'} +
+
+ `).join('') + : '
No recent match activity.
'; +} + +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); diff --git a/public/icons/airburst_rocket_launcher.svg b/public/icons/airburst_rocket_launcher.svg index 2056ef4..ec0d381 100644 --- a/public/icons/airburst_rocket_launcher.svg +++ b/public/icons/airburst_rocket_launcher.svg @@ -1 +1,15 @@ - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/anti_materiel_rifle.svg b/public/icons/anti_materiel_rifle.svg index 39ed438..4a292d7 100644 --- a/public/icons/anti_materiel_rifle.svg +++ b/public/icons/anti_materiel_rifle.svg @@ -1 +1,15 @@ - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/anti_personnel_minefield.svg b/public/icons/anti_personnel_minefield.svg index b01f92c..786b792 100644 --- a/public/icons/anti_personnel_minefield.svg +++ b/public/icons/anti_personnel_minefield.svg @@ -1,5 +1,15 @@ - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/anti_tank_emplacement.svg b/public/icons/anti_tank_emplacement.svg index c00222a..88170b4 100644 --- a/public/icons/anti_tank_emplacement.svg +++ b/public/icons/anti_tank_emplacement.svg @@ -1 +1,16 @@ - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + + diff --git a/public/icons/anti_tank_mines.svg b/public/icons/anti_tank_mines.svg index 1843037..6d4ee78 100644 --- a/public/icons/anti_tank_mines.svg +++ b/public/icons/anti_tank_mines.svg @@ -1 +1,15 @@ - + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/arc_thrower.svg b/public/icons/arc_thrower.svg index 8ced9b2..a7b2a01 100644 --- a/public/icons/arc_thrower.svg +++ b/public/icons/arc_thrower.svg @@ -1,4 +1,15 @@ - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/autocannon.svg b/public/icons/autocannon.svg index ec09df6..d78e48c 100644 --- a/public/icons/autocannon.svg +++ b/public/icons/autocannon.svg @@ -1,4 +1,15 @@ - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/autocannon_sentry.svg b/public/icons/autocannon_sentry.svg index a01e7d9..938d29f 100644 --- a/public/icons/autocannon_sentry.svg +++ b/public/icons/autocannon_sentry.svg @@ -1,6 +1,15 @@ - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/ballistic_shield_backpack.svg b/public/icons/ballistic_shield_backpack.svg index 0dcd29d..ed59d2a 100644 --- a/public/icons/ballistic_shield_backpack.svg +++ b/public/icons/ballistic_shield_backpack.svg @@ -1,8 +1,15 @@ - - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/commando.svg b/public/icons/commando.svg index 40117f4..1d89165 100644 --- a/public/icons/commando.svg +++ b/public/icons/commando.svg @@ -1,7 +1,15 @@ - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/directional_shield.svg b/public/icons/directional_shield.svg index bbbe779..b8cb38e 100644 --- a/public/icons/directional_shield.svg +++ b/public/icons/directional_shield.svg @@ -1 +1,15 @@ - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/eagle_110mm_rocket_pods.svg b/public/icons/eagle_110mm_rocket_pods.svg index d89638e..5913399 100644 --- a/public/icons/eagle_110mm_rocket_pods.svg +++ b/public/icons/eagle_110mm_rocket_pods.svg @@ -1,9 +1,15 @@ - - - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/eagle_500kg_bomb.svg b/public/icons/eagle_500kg_bomb.svg index 1c5ebb2..5e40113 100644 --- a/public/icons/eagle_500kg_bomb.svg +++ b/public/icons/eagle_500kg_bomb.svg @@ -1,7 +1,15 @@ - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/eagle_airstrike.svg b/public/icons/eagle_airstrike.svg index 185eb3c..fa8d2e1 100644 --- a/public/icons/eagle_airstrike.svg +++ b/public/icons/eagle_airstrike.svg @@ -1,7 +1,15 @@ - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/eagle_cluster_bomb.svg b/public/icons/eagle_cluster_bomb.svg index c1d1cc6..23141d7 100644 --- a/public/icons/eagle_cluster_bomb.svg +++ b/public/icons/eagle_cluster_bomb.svg @@ -1,9 +1,20 @@ - - - - - - - + + Traced by Dogo314 + + + + + + + + + + + + + + + + diff --git a/public/icons/eagle_napalm_airstrike.svg b/public/icons/eagle_napalm_airstrike.svg index ad9b890..d3beea4 100644 --- a/public/icons/eagle_napalm_airstrike.svg +++ b/public/icons/eagle_napalm_airstrike.svg @@ -1,7 +1,15 @@ - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/eagle_rearm.svg b/public/icons/eagle_rearm.svg index a3551ee..45a30e2 100644 --- a/public/icons/eagle_rearm.svg +++ b/public/icons/eagle_rearm.svg @@ -1,6 +1,15 @@ - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/eagle_smoke_strike.svg b/public/icons/eagle_smoke_strike.svg index 3780320..52ebe96 100644 --- a/public/icons/eagle_smoke_strike.svg +++ b/public/icons/eagle_smoke_strike.svg @@ -1,7 +1,15 @@ - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/eagle_strafing_run.svg b/public/icons/eagle_strafing_run.svg index e94ec15..3085126 100644 --- a/public/icons/eagle_strafing_run.svg +++ b/public/icons/eagle_strafing_run.svg @@ -1,14 +1,22 @@ - - - - - - - - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + + + + + + + + diff --git a/public/icons/emancipator_exosuit.svg b/public/icons/emancipator_exosuit.svg index 41fb9a2..44b81a1 100644 --- a/public/icons/emancipator_exosuit.svg +++ b/public/icons/emancipator_exosuit.svg @@ -1 +1,15 @@ - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/ems_mortar_sentry.svg b/public/icons/ems_mortar_sentry.svg index 291fee1..76f4b8c 100644 --- a/public/icons/ems_mortar_sentry.svg +++ b/public/icons/ems_mortar_sentry.svg @@ -1,5 +1,15 @@ - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/expendable_anti_tank.svg b/public/icons/expendable_anti_tank.svg index c186324..f616d83 100644 --- a/public/icons/expendable_anti_tank.svg +++ b/public/icons/expendable_anti_tank.svg @@ -1,4 +1,15 @@ - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/flamethrower.svg b/public/icons/flamethrower.svg index c27eb34..e645824 100644 --- a/public/icons/flamethrower.svg +++ b/public/icons/flamethrower.svg @@ -1,4 +1,15 @@ - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/gatling_sentry.svg b/public/icons/gatling_sentry.svg index 3bcefdf..8bfd1b9 100644 --- a/public/icons/gatling_sentry.svg +++ b/public/icons/gatling_sentry.svg @@ -1,8 +1,19 @@ - - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + + + + + diff --git a/public/icons/grenade_launcher.svg b/public/icons/grenade_launcher.svg index 555fc5f..a09c477 100644 --- a/public/icons/grenade_launcher.svg +++ b/public/icons/grenade_launcher.svg @@ -1 +1,15 @@ - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/guard_dog.svg b/public/icons/guard_dog.svg index 2d1626c..8d04e57 100644 --- a/public/icons/guard_dog.svg +++ b/public/icons/guard_dog.svg @@ -1,8 +1,15 @@ - - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/guard_dog_rover.svg b/public/icons/guard_dog_rover.svg index d0dec92..54c8f08 100644 --- a/public/icons/guard_dog_rover.svg +++ b/public/icons/guard_dog_rover.svg @@ -1,8 +1,15 @@ - - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/heavy_machine_gun.svg b/public/icons/heavy_machine_gun.svg index f3fac24..f223977 100644 --- a/public/icons/heavy_machine_gun.svg +++ b/public/icons/heavy_machine_gun.svg @@ -1,4 +1,15 @@ - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/hellbomb.svg b/public/icons/hellbomb.svg index fe9c084..1d8f6fc 100644 --- a/public/icons/hellbomb.svg +++ b/public/icons/hellbomb.svg @@ -1,5 +1,15 @@ - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/hmg_emplacement.svg b/public/icons/hmg_emplacement.svg index d5e02ef..562b130 100644 --- a/public/icons/hmg_emplacement.svg +++ b/public/icons/hmg_emplacement.svg @@ -1,7 +1,15 @@ - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/incendiary_mines.svg b/public/icons/incendiary_mines.svg index 5fbd866..b204ba9 100644 --- a/public/icons/incendiary_mines.svg +++ b/public/icons/incendiary_mines.svg @@ -1,5 +1,15 @@ - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/laser_cannon.svg b/public/icons/laser_cannon.svg index 4091779..8c2fba9 100644 --- a/public/icons/laser_cannon.svg +++ b/public/icons/laser_cannon.svg @@ -1,4 +1,15 @@ - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/lift_850_jump_pack.svg b/public/icons/lift_850_jump_pack.svg index 92eacef..160e137 100644 --- a/public/icons/lift_850_jump_pack.svg +++ b/public/icons/lift_850_jump_pack.svg @@ -1,6 +1,15 @@ - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/machine_gun.svg b/public/icons/machine_gun.svg index b1be61a..78cb13e 100644 --- a/public/icons/machine_gun.svg +++ b/public/icons/machine_gun.svg @@ -1,4 +1,15 @@ - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/machine_gun_sentry.svg b/public/icons/machine_gun_sentry.svg index 5c8bfa8..e1e322b 100644 --- a/public/icons/machine_gun_sentry.svg +++ b/public/icons/machine_gun_sentry.svg @@ -1,6 +1,15 @@ - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/mortar_sentry.svg b/public/icons/mortar_sentry.svg index 1293d06..846eac7 100644 --- a/public/icons/mortar_sentry.svg +++ b/public/icons/mortar_sentry.svg @@ -1,5 +1,15 @@ - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/orbital_120mm_he_barrage.svg b/public/icons/orbital_120mm_he_barrage.svg index b0a51a3..2bbb468 100644 --- a/public/icons/orbital_120mm_he_barrage.svg +++ b/public/icons/orbital_120mm_he_barrage.svg @@ -1,6 +1,15 @@ - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/orbital_380mm_he_barrage.svg b/public/icons/orbital_380mm_he_barrage.svg index 7d4c540..2448053 100644 --- a/public/icons/orbital_380mm_he_barrage.svg +++ b/public/icons/orbital_380mm_he_barrage.svg @@ -1,8 +1,15 @@ - - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/orbital_airburst_strike.svg b/public/icons/orbital_airburst_strike.svg index 170ecff..0f61718 100644 --- a/public/icons/orbital_airburst_strike.svg +++ b/public/icons/orbital_airburst_strike.svg @@ -1,17 +1,15 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/orbital_ems_strike.svg b/public/icons/orbital_ems_strike.svg index 595b60d..5c7b8bb 100644 --- a/public/icons/orbital_ems_strike.svg +++ b/public/icons/orbital_ems_strike.svg @@ -1,5 +1,15 @@ - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/orbital_gas_strike.svg b/public/icons/orbital_gas_strike.svg index ffc7ac0..1a78d76 100644 --- a/public/icons/orbital_gas_strike.svg +++ b/public/icons/orbital_gas_strike.svg @@ -1,4 +1,15 @@ - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/orbital_gatling_barrage.svg b/public/icons/orbital_gatling_barrage.svg index 792c237..4e50026 100644 --- a/public/icons/orbital_gatling_barrage.svg +++ b/public/icons/orbital_gatling_barrage.svg @@ -1,13 +1,15 @@ - - - - - - - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/orbital_illumination_flare.svg b/public/icons/orbital_illumination_flare.svg index 47b8941..45ca394 100644 --- a/public/icons/orbital_illumination_flare.svg +++ b/public/icons/orbital_illumination_flare.svg @@ -1,15 +1,14 @@ - - - - - - - - - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + diff --git a/public/icons/orbital_laser.svg b/public/icons/orbital_laser.svg index 40a43d2..293af95 100644 --- a/public/icons/orbital_laser.svg +++ b/public/icons/orbital_laser.svg @@ -1,8 +1,15 @@ - - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/orbital_precision_strike.svg b/public/icons/orbital_precision_strike.svg index 948ea3a..9f492cf 100644 --- a/public/icons/orbital_precision_strike.svg +++ b/public/icons/orbital_precision_strike.svg @@ -1,5 +1,15 @@ - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/orbital_railcannon_strike.svg b/public/icons/orbital_railcannon_strike.svg index 55b45dc..bf39ece 100644 --- a/public/icons/orbital_railcannon_strike.svg +++ b/public/icons/orbital_railcannon_strike.svg @@ -1,5 +1,17 @@ - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + + + diff --git a/public/icons/orbital_smoke_strike.svg b/public/icons/orbital_smoke_strike.svg index f55b6df..94c27ba 100644 --- a/public/icons/orbital_smoke_strike.svg +++ b/public/icons/orbital_smoke_strike.svg @@ -1,4 +1,15 @@ - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/orbital_walking_barrage.svg b/public/icons/orbital_walking_barrage.svg index c49ebfe..e58dea5 100644 --- a/public/icons/orbital_walking_barrage.svg +++ b/public/icons/orbital_walking_barrage.svg @@ -1,8 +1,15 @@ - - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/patriot_exosuit.svg b/public/icons/patriot_exosuit.svg index 7c7562e..131a801 100644 --- a/public/icons/patriot_exosuit.svg +++ b/public/icons/patriot_exosuit.svg @@ -1,8 +1,17 @@ - - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + + + diff --git a/public/icons/prospecting_drill.svg b/public/icons/prospecting_drill.svg index e0a4d92..1328b83 100644 --- a/public/icons/prospecting_drill.svg +++ b/public/icons/prospecting_drill.svg @@ -1,4 +1,14 @@ - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + diff --git a/public/icons/quasar_cannon.svg b/public/icons/quasar_cannon.svg index 5dc4cf8..43d516a 100644 --- a/public/icons/quasar_cannon.svg +++ b/public/icons/quasar_cannon.svg @@ -1,8 +1,15 @@ - - - - + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/railgun.svg b/public/icons/railgun.svg index 61d0569..3b12f5f 100644 --- a/public/icons/railgun.svg +++ b/public/icons/railgun.svg @@ -1,5 +1,17 @@ - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + + + diff --git a/public/icons/recoilless_rifle.svg b/public/icons/recoilless_rifle.svg index 62f0c22..7b682a2 100644 --- a/public/icons/recoilless_rifle.svg +++ b/public/icons/recoilless_rifle.svg @@ -1,4 +1,15 @@ - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/reinforce.svg b/public/icons/reinforce.svg index 8c90ed5..3ece19a 100644 --- a/public/icons/reinforce.svg +++ b/public/icons/reinforce.svg @@ -1,4 +1,14 @@ - - - - + + Traced by Dogo314 + + + + + + + + + + + + diff --git a/public/icons/resupply.svg b/public/icons/resupply.svg index 31f543e..6a96f08 100644 --- a/public/icons/resupply.svg +++ b/public/icons/resupply.svg @@ -1,5 +1,15 @@ - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/rocket_sentry.svg b/public/icons/rocket_sentry.svg index 31df2a3..1f81912 100644 --- a/public/icons/rocket_sentry.svg +++ b/public/icons/rocket_sentry.svg @@ -1,5 +1,15 @@ - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/seaf_artillery.svg b/public/icons/seaf_artillery.svg index 9fa7308..8c96036 100644 --- a/public/icons/seaf_artillery.svg +++ b/public/icons/seaf_artillery.svg @@ -1,4 +1,15 @@ - - - - + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/shield_generator_pack.svg b/public/icons/shield_generator_pack.svg index e59f50b..99f5fee 100644 --- a/public/icons/shield_generator_pack.svg +++ b/public/icons/shield_generator_pack.svg @@ -1,8 +1,15 @@ - - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/shield_generator_relay.svg b/public/icons/shield_generator_relay.svg index dd2ac16..ff0083f 100644 --- a/public/icons/shield_generator_relay.svg +++ b/public/icons/shield_generator_relay.svg @@ -1,4 +1,15 @@ - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/sos_beacon.svg b/public/icons/sos_beacon.svg index 0ed2911..285b0f0 100644 --- a/public/icons/sos_beacon.svg +++ b/public/icons/sos_beacon.svg @@ -1,4 +1,14 @@ - - - - + + Traced by Dogo314 + + + + + + + + + + + + diff --git a/public/icons/spear.svg b/public/icons/spear.svg index 625f1e8..8c8ab76 100644 --- a/public/icons/spear.svg +++ b/public/icons/spear.svg @@ -1,4 +1,15 @@ - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/stalwart.svg b/public/icons/stalwart.svg index 9f501c3..a4c8a99 100644 --- a/public/icons/stalwart.svg +++ b/public/icons/stalwart.svg @@ -1 +1,15 @@ - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/supply_pack.svg b/public/icons/supply_pack.svg index 6d8640c..dcc39ec 100644 --- a/public/icons/supply_pack.svg +++ b/public/icons/supply_pack.svg @@ -1,8 +1,15 @@ - - - - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/tesla_tower.svg b/public/icons/tesla_tower.svg index 38abc5d..1aacf80 100644 --- a/public/icons/tesla_tower.svg +++ b/public/icons/tesla_tower.svg @@ -1,5 +1,15 @@ - - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + + diff --git a/public/icons/upload_data.svg b/public/icons/upload_data.svg index 67498e2..4cbd5ac 100644 --- a/public/icons/upload_data.svg +++ b/public/icons/upload_data.svg @@ -1,4 +1,14 @@ - - - - \ No newline at end of file + + Traced by Dogo314 + + + + + + + + + + + + diff --git a/public/styles.css b/public/styles.css index d930721..f2296d0 100644 --- a/public/styles.css +++ b/public/styles.css @@ -3,6 +3,7 @@ --bg: #0a0a10; --bg-surface: #0f0f1e; --bg-surface2: #141428; + --bg-surface3: #191a30; --bg-glass: rgba(15, 15, 30, 0.85); --accent: #ffe710; --accent-dim: rgba(255, 231, 16, 0.12); @@ -18,10 +19,11 @@ --text-muted: #4a5070; --text-dim: #7a86a8; --border: rgba(65, 99, 156, 0.22); + --border-dim: rgba(65, 99, 156, 0.12); --border-accent: rgba(255, 231, 16, 0.28); --gradient-accent: linear-gradient(135deg, #ffe710 0%, #ffb800 100%); - --gradient-bg: linear-gradient(160deg, #0a0a10 0%, #0c0c1c 100%); + --gradient-bg: radial-gradient(circle at top, rgba(255, 231, 16, 0.08), transparent 32%), radial-gradient(circle at 20% 20%, rgba(65, 99, 156, 0.18), transparent 28%), linear-gradient(160deg, #07080d 0%, #0c0c1c 48%, #090c16 100%); --shadow-card: 0 4px 28px rgba(0, 0, 0, 0.55); --shadow-accent: 0 0 20px rgba(255, 231, 16, 0.35); --shadow-glow: 0 0 40px rgba(255, 231, 16, 0.15); @@ -31,7 +33,9 @@ --font-body: 'Exo 2', system-ui, sans-serif; --radius: 4px; + --radius-md: 6px; --radius-lg: 8px; + --radius-xl: 18px; --transition: 0.15s ease; --trans-med: 0.25s ease; } @@ -41,7 +45,7 @@ html, body { height: 100%; - background: var(--bg); + background: var(--gradient-bg); color: var(--text); font-family: var(--font-body); font-size: 16px; @@ -78,6 +82,15 @@ body::after { z-index: 0; } +body { + position: relative; +} + +body > * { + position: relative; + z-index: 1; +} + /* ── Layout helpers ────────────────────────────────────────────────────────── */ .hidden { display: none !important; } .muted { color: var(--text-muted); font-size: 0.85rem; } @@ -87,8 +100,8 @@ body::after { position: relative; z-index: 1; min-height: calc(100vh - 56px); - padding: 28px 24px 64px; - max-width: 1140px; + padding: 34px 24px 72px; + max-width: 1200px; margin: 0 auto; } @@ -122,9 +135,10 @@ body::after { gap: 16px; padding: 0 20px; height: 56px; - background: rgba(10, 10, 16, 0.94); - border-bottom: 1px solid var(--border); + background: rgba(7, 8, 13, 0.8); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); backdrop-filter: blur(12px); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); } .nav-brand { @@ -270,14 +284,27 @@ body::after { .card { background: var(--bg-glass); border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 22px; + border-radius: var(--radius-xl); + padding: 24px; box-shadow: var(--shadow-card); - backdrop-filter: blur(8px); - transition: border-color var(--trans-med), box-shadow var(--trans-med); + backdrop-filter: blur(14px); + transition: border-color var(--trans-med), box-shadow var(--trans-med), transform var(--trans-med); + position: relative; + overflow: hidden; } -.card:hover { border-color: rgba(65, 99, 156, 0.4); } +.card::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent 24%); + pointer-events: none; +} + +.card:hover { + border-color: rgba(65, 99, 156, 0.4); + transform: translateY(-2px); +} .card-accent { border-color: var(--border-accent); @@ -285,9 +312,9 @@ body::after { } .card-hero { - background: linear-gradient(135deg, rgba(65, 99, 156, 0.15) 0%, rgba(255, 231, 16, 0.04) 100%); + background: linear-gradient(135deg, rgba(65, 99, 156, 0.2) 0%, rgba(20, 26, 45, 0.9) 52%, rgba(255, 231, 16, 0.08) 100%); border-color: rgba(65, 99, 156, 0.35); - padding: 28px; + padding: 30px; } .card-title { @@ -301,6 +328,8 @@ body::after { display: flex; align-items: center; gap: 8px; + position: relative; + z-index: 1; } .card-title::before { @@ -361,7 +390,7 @@ body::after { gap: 6px; padding: 10px 22px; border: 1px solid transparent; - border-radius: var(--radius); + border-radius: 999px; font-family: var(--font-heading); font-size: 0.9rem; font-weight: 700; @@ -419,6 +448,24 @@ body::after { .btn-full { width: 100%; } .btn-icon { padding: 8px; min-width: 36px; } +.eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--brand-light); +} + +.eyebrow::before { + content: ''; + width: 30px; + height: 1px; + background: linear-gradient(90deg, var(--brand), transparent); +} + /* Loading state */ .btn-loading { pointer-events: none; opacity: 0.7; } .btn-loading::after { @@ -608,15 +655,84 @@ select option { background: var(--bg-surface2); } /* ── Login ─────────────────────────────────────────────────────────────────── */ .login-box { width: 100%; - max-width: 400px; + max-width: 420px; background: var(--bg-glass); border: 1px solid var(--border); - border-radius: var(--radius-lg); + border-radius: var(--radius-xl); padding: 40px 36px; box-shadow: var(--shadow-card); backdrop-filter: blur(16px); } +.login-shell { + width: min(1080px, 100%); + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(340px, 420px); + gap: 24px; + align-items: stretch; +} + +.login-intel { + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 100%; + padding: 36px; + background: + radial-gradient(circle at top right, rgba(255, 231, 16, 0.1), transparent 26%), + linear-gradient(145deg, rgba(15, 19, 33, 0.94), rgba(9, 12, 20, 0.92)); +} + +.login-intel-title { + margin-top: 18px; + max-width: 10ch; + font-family: var(--font-heading); + font-size: clamp(2.4rem, 5vw, 4.3rem); + line-height: 0.92; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #f7f9ff; +} + +.login-intel-copy { + max-width: 38rem; + margin-top: 18px; + color: var(--text-dim); + font-size: 1rem; +} + +.login-intel-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-top: 28px; +} + +.intel-stat { + background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01)); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + min-height: 110px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.intel-stat-value { + font-family: var(--font-heading); + font-size: 2rem; + line-height: 1; + color: var(--accent); +} + +.intel-stat-label { + color: var(--text-muted); + font-size: 0.76rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + .login-header { text-align: center; margin-bottom: 32px; } .login-logo { @@ -666,7 +782,74 @@ select option { background: var(--bg-surface2); } .dashboard-grid { display: grid; grid-template-columns: repeat(3, 1fr); - gap: 16px; + gap: 18px; +} + +.dashboard-briefing { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(0, 1.5fr) auto; + gap: 24px; + align-items: center; + background: + radial-gradient(circle at top right, rgba(255, 231, 16, 0.12), transparent 28%), + linear-gradient(130deg, rgba(11, 16, 28, 0.9), rgba(18, 22, 38, 0.92)); +} + +.briefing-title { + margin-top: 10px; + max-width: 20ch; + font-family: var(--font-heading); + font-size: clamp(1.8rem, 3vw, 2.8rem); + line-height: 0.98; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.briefing-text { + margin-top: 12px; + max-width: 44rem; + color: var(--text-dim); + font-size: 0.98rem; +} + +.briefing-pills { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 18px; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 12px; + min-width: 155px; + padding: 10px 14px; + background: rgba(255,255,255,0.03); + border: 1px solid var(--border); + border-radius: 999px; +} + +.status-pill-label { + font-size: 0.72rem; + color: var(--text-muted); + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.status-pill strong { + font-family: var(--font-heading); + font-size: 1rem; + color: var(--accent); + letter-spacing: 0.06em; +} + +.briefing-actions { + display: flex; + flex-direction: column; + gap: 10px; + align-items: stretch; } .dashboard-hero { @@ -715,11 +898,18 @@ select option { background: var(--bg-surface2); } } .hero-stats { - display: flex; - gap: 24px; + display: grid; + grid-template-columns: repeat(4, minmax(92px, 1fr)); + gap: 12px; } -.hero-stat-item { text-align: center; } +.hero-stat-item { + text-align: center; + padding: 14px 12px; + border-radius: 14px; + background: rgba(5, 9, 18, 0.4); + border: 1px solid rgba(255,255,255,0.05); +} .hero-stat-val { font-family: var(--font-mono); font-size: 1.5rem; @@ -767,6 +957,16 @@ select option { background: var(--bg-surface2); } /* Daily challenge */ .daily-stratagem { display: flex; flex-direction: column; gap: 8px; } +.daily-icon-wrap { + width: 74px; + height: 74px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 18px; + background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01)); + border: 1px solid var(--border); +} .daily-name { font-family: var(--font-heading); @@ -808,7 +1008,7 @@ select option { background: var(--bg-surface2); } gap: 10px; padding: 8px 12px; background: var(--bg-surface2); - border-radius: var(--radius); + border-radius: 14px; border: 1px solid var(--border); } @@ -828,24 +1028,32 @@ select option { background: var(--bg-surface2); } font-size: 0.88rem; } +.table-wrap { + overflow-x: auto; + margin: 0 -4px; + padding: 0 4px; + position: relative; + z-index: 1; +} + .data-table th { text-align: left; font-size: 0.68rem; letter-spacing: 0.1em; color: var(--text-muted); - padding: 6px 8px; - border-bottom: 1px solid var(--border); + padding: 8px 10px 12px; + border-bottom: 1px solid rgba(255,255,255,0.08); text-transform: uppercase; } .data-table td { - padding: 9px 8px; + padding: 12px 10px; border-bottom: 1px solid rgba(65, 99, 156, 0.08); font-family: var(--font-mono); font-size: 0.84rem; } -.data-table tbody tr:hover td { background: rgba(65, 99, 156, 0.06); } +.data-table tbody tr:hover td { background: rgba(65, 99, 156, 0.08); } .data-table tr.row-me td { color: var(--accent); } .data-table .rank { font-size: 0.95rem; @@ -869,15 +1077,15 @@ select option { background: var(--bg-surface2); } .mode-grid { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 12px; + gap: 14px; margin-bottom: 24px; } .mode-card { - background: var(--bg-surface); + background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)); border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 18px 14px; + border-radius: 18px; + padding: 20px 16px; text-align: center; cursor: pointer; transition: all var(--trans-med); @@ -886,8 +1094,8 @@ select option { background: var(--bg-surface2); } gap: 8px; } -.mode-card:hover { border-color: var(--brand); background: var(--brand-dim); } -.mode-card.active { border-color: var(--accent); background: var(--accent-dim); box-shadow: 0 0 14px rgba(255,231,16,0.1); } +.mode-card:hover { border-color: var(--brand); background: rgba(65, 99, 156, 0.14); transform: translateY(-2px); } +.mode-card.active { border-color: var(--accent); background: linear-gradient(180deg, rgba(255, 231, 16, 0.12), rgba(255, 231, 16, 0.04)); box-shadow: 0 0 14px rgba(255,231,16,0.1); } .mode-icon { font-size: 1.8rem; line-height: 1; } @@ -914,7 +1122,7 @@ select option { background: var(--bg-surface2); } background: var(--bg-surface); border: 1px solid var(--border); color: var(--text-muted); - border-radius: 20px; + border-radius: 999px; padding: 5px 14px; font-family: var(--font-body); font-size: 0.78rem; @@ -931,7 +1139,10 @@ select option { background: var(--bg-surface2); } flex-direction: column; align-items: center; gap: 24px; - padding: 32px 20px; + padding: 40px 20px; + background: linear-gradient(180deg, rgba(255,255,255,0.03), transparent 55%); + border: 1px dashed var(--border); + border-radius: var(--radius-xl); } .practice-start-row { @@ -950,12 +1161,106 @@ select option { background: var(--bg-surface2); } flex-direction: column; align-items: center; gap: 20px; + scroll-margin-top: 88px; +} + +.practice-focus-shell { + width: min(960px, 100%); + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 18px; + border-radius: 24px; + background: linear-gradient(180deg, rgba(8, 11, 18, 0.86), rgba(14, 17, 29, 0.76)); + border: 1px solid rgba(255,255,255,0.06); + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35); +} + +body.in-practice-session #view-practice .practice-header, +body.in-practice-session #view-practice #practice-mode-grid, +body.in-practice-session #view-practice #practice-categories { + display: none; +} + +body.in-practice-session #view-practice { + padding-top: 18px; +} + +body.in-practice-session #view-practice .practice-active { + min-height: calc(100vh - 120px); + justify-content: center; +} + +.practice-focus-meta { + width: 100%; + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; +} + +.focus-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 120px; + padding: 8px 14px; + border-radius: 999px; + border: 1px solid rgba(255, 231, 16, 0.28); + background: rgba(255, 231, 16, 0.1); + color: var(--accent); + font-family: var(--font-heading); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.focus-chip-muted { + border-color: var(--border); + background: rgba(255,255,255,0.04); + color: var(--text-dim); +} + +.gameplay-feedback { + width: 100%; + max-width: 620px; + padding: 10px 16px; + border-radius: 999px; + text-align: center; + font-family: var(--font-heading); + font-size: 0.84rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + border: 1px solid var(--border); + background: rgba(255,255,255,0.05); +} + +.gameplay-feedback-info { + color: var(--text); + border-color: rgba(65, 99, 156, 0.28); + background: rgba(65, 99, 156, 0.14); +} + +.gameplay-feedback-success { + color: #062013; + border-color: rgba(77,255,145,0.35); + background: linear-gradient(180deg, #99ffc1, #43d97c); +} + +.gameplay-feedback-danger { + color: #2c0509; + border-color: rgba(255,82,93,0.35); + background: linear-gradient(180deg, #ff9aa1, #ff5c66); } .stratagem-display { width: 100%; - max-width: 620px; + max-width: 100%; text-align: center; + background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)); } .stratagem-category { @@ -988,57 +1293,103 @@ select option { background: var(--bg-surface2); } .arrow-sequence { display: flex; flex-wrap: wrap; - gap: 8px; + gap: 10px; justify-content: center; align-items: center; min-height: 58px; } +.arrow-sequence-hero { + min-height: 96px; + padding: 16px 18px; + border-radius: 22px; + background: linear-gradient(180deg, rgba(6, 10, 18, 0.92), rgba(15, 20, 33, 0.92)); + border: 1px solid rgba(255,255,255,0.07); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 16px 32px rgba(0,0,0,0.28); +} + .arrow-key { display: flex; align-items: center; justify-content: center; - width: 50px; - height: 50px; + width: 62px; + height: 62px; border: 2px solid var(--border); - border-radius: 6px; + border-radius: 16px; font-family: var(--font-mono); - font-size: 1.3rem; - color: var(--text-muted); - background: var(--bg-surface2); + font-size: 1.9rem; + font-weight: 700; + color: #edf2ff; + background: linear-gradient(180deg, rgba(31, 39, 61, 0.98), rgba(15, 20, 31, 0.98)); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.06); transition: all 0.1s ease; user-select: none; } +.dir-glyph { + display: inline-flex; + width: 1.5em; + height: 1.5em; + align-items: center; + justify-content: center; +} + +.dir-glyph svg { + width: 100%; + height: 100%; + overflow: visible; +} + +.dir-glyph .dir-line, +.dir-glyph .dir-head { + fill: currentColor; + stroke: currentColor; + stroke-width: 0; +} + +.dir-glyph .dir-line { + fill: none; + stroke-width: 5; + stroke-linecap: square; +} + +.dir-up { transform: rotate(0deg); } +.dir-right { transform: rotate(90deg); } +.dir-down { transform: rotate(180deg); } +.dir-left { transform: rotate(270deg); } + .arrow-key.active { border-color: var(--accent); - color: var(--accent); - box-shadow: 0 0 14px rgba(255,231,16,0.4); + color: #111; + background: linear-gradient(180deg, #fff07a, var(--accent)); + box-shadow: 0 0 26px rgba(255,231,16,0.35), 0 8px 24px rgba(255, 184, 0, 0.22); + transform: translateY(-2px) scale(1.06); animation: pulse-key 0.9s ease infinite; } @keyframes pulse-key { - 0%, 100% { box-shadow: 0 0 14px rgba(255,231,16,0.4); } - 50% { box-shadow: 0 0 22px rgba(255,231,16,0.6); } + 0%, 100% { box-shadow: 0 0 20px rgba(255,231,16,0.35), 0 8px 24px rgba(255, 184, 0, 0.18); } + 50% { box-shadow: 0 0 32px rgba(255,231,16,0.55), 0 12px 28px rgba(255, 184, 0, 0.28); } } .arrow-key.completed { border-color: var(--success); - color: var(--success); - background: var(--success-dim); + color: #04180d; + background: linear-gradient(180deg, #87ffb3, #2ed06e); + box-shadow: 0 8px 22px rgba(77,255,145,0.2); } .arrow-key.flash-correct { border-color: var(--success); - background: var(--success-dim); - color: var(--success); + background: linear-gradient(180deg, #87ffb3, #2ed06e); + color: #04180d; animation: pop 0.22s ease; } .arrow-key.flash-wrong { border-color: var(--danger); - background: var(--danger-dim); - color: var(--danger); + background: linear-gradient(180deg, #ff8d95, #ff525d); + color: #230207; animation: shake 0.35s ease; } @@ -1062,6 +1413,11 @@ select option { background: var(--bg-surface2); } align-items: center; justify-content: center; flex-wrap: wrap; + width: 100%; + padding: 12px 16px; + border-radius: 18px; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.06); } .hud-item { text-align: center; min-width: 72px; } @@ -1187,13 +1543,13 @@ select option { background: var(--bg-surface2); } } .dpad-btn { - width: 58px; - height: 58px; - background: var(--bg-surface); - border: 1px solid var(--border); - border-radius: var(--radius); + width: 72px; + height: 72px; + background: linear-gradient(180deg, rgba(33, 41, 61, 0.98), rgba(15, 20, 31, 0.98)); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 18px; color: var(--text); - font-size: 1.3rem; + font-size: 1.65rem; cursor: pointer; transition: all var(--transition); font-family: var(--font-mono); @@ -1203,14 +1559,19 @@ select option { background: var(--bg-surface2); } -webkit-tap-highlight-color: transparent; } -.dpad-btn:hover { background: var(--brand-dim); border-color: var(--brand); } -.dpad-btn:active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); transform: scale(0.9); } +.dpad-btn .dir-glyph { + width: 1.35em; + height: 1.35em; +} + +.dpad-btn:hover { background: rgba(65, 99, 156, 0.24); border-color: var(--brand); } +.dpad-btn:active { background: rgba(255, 231, 16, 0.18); border-color: var(--accent); color: var(--accent); transform: scale(0.94); } .dpad-center { - width: 58px; - height: 58px; + width: 72px; + height: 72px; background: var(--bg-surface2); - border-radius: var(--radius); + border-radius: 18px; } /* ── Lobby ─────────────────────────────────────────────────────────────────── */ @@ -1229,7 +1590,7 @@ select option { background: var(--bg-surface2); } gap: 12px; padding: 12px 14px; background: var(--bg-surface2); - border-radius: var(--radius); + border-radius: 14px; border: 1px solid var(--border); transition: border-color var(--transition); } @@ -1357,6 +1718,25 @@ select option { background: var(--bg-surface2); } max-width: 720px; margin: 0 auto; padding: 0 20px; + scroll-margin-top: 88px; +} + +.match-focus-shell { + width: min(980px, 100%); + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + padding: 18px; + border-radius: 24px; + background: linear-gradient(180deg, rgba(8, 11, 18, 0.84), rgba(15, 20, 31, 0.78)); + border: 1px solid rgba(255,255,255,0.06); + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35); +} + +body.in-match-round #view-match .match-header, +body.in-match-round #view-match .match-actions { + opacity: 0.72; } .match-sequences { @@ -1376,6 +1756,48 @@ select option { background: var(--bg-surface2); } text-transform: uppercase; } +.duel-progress { + width: min(320px, 100%); + display: flex; + align-items: center; + gap: 10px; +} + +.duel-progress-bar { + flex: 1; + height: 10px; + border-radius: 999px; + overflow: hidden; + background: rgba(255,255,255,0.08); + border: 1px solid rgba(255,255,255,0.06); +} + +.duel-progress-fill { + height: 100%; + width: 0; + border-radius: 999px; + background: linear-gradient(90deg, #fff07a, var(--accent)); + transition: width 0.14s ease; +} + +.duel-progress-fill-opp { + background: linear-gradient(90deg, var(--brand-light), #8db5ff); +} + +.duel-progress-text { + min-width: 54px; + text-align: right; + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-dim); +} + +.arrow-sequence-duel .arrow-key { + width: 58px; + height: 58px; + font-size: 1.6rem; +} + .match-actions { position: relative; z-index: 1; @@ -1386,6 +1808,59 @@ select option { background: var(--bg-surface2); } padding: 16px; } +.summary-insights { + display: flex; + flex-direction: column; + gap: 8px; +} + +.summary-insight { + padding: 10px 12px; + border-radius: 14px; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.06); + color: var(--text-dim); + font-size: 0.9rem; +} + +.gameplay-countdown { + position: fixed; + inset: 0; + z-index: 950; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + background: radial-gradient(circle, rgba(8, 10, 18, 0.28), rgba(8, 10, 18, 0.82)); + backdrop-filter: blur(8px); + pointer-events: none; +} + +.gameplay-countdown strong { + font-family: var(--font-heading); + font-size: clamp(4rem, 18vw, 9rem); + line-height: 0.9; + color: var(--accent); + text-shadow: 0 0 32px rgba(255,231,16,0.35); +} + +.countdown-label { + font-size: 0.9rem; + letter-spacing: 0.28em; + text-transform: uppercase; + color: var(--text-dim); +} + +.countdown-pop strong { + animation: countdownPop 0.42s ease; +} + +@keyframes countdownPop { + 0% { transform: scale(0.75); opacity: 0.35; } + 100% { transform: scale(1); opacity: 1; } +} + /* ── Post-match result ─────────────────────────────────────────────────────── */ .result-winner { font-family: var(--font-heading); font-size: 2rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .result-winner.win { color: var(--accent); text-shadow: var(--shadow-accent); } @@ -1434,7 +1909,7 @@ select option { background: var(--bg-surface2); } .tab-btn { background: transparent; border: 1px solid var(--border); - border-radius: var(--radius); + border-radius: 999px; color: var(--text-muted); font-family: var(--font-heading); font-size: 0.82rem; @@ -1454,7 +1929,7 @@ select option { background: var(--bg-surface2); } width: 100%; height: 160px; background: var(--bg-surface2); - border-radius: var(--radius); + border-radius: 16px; border: 1px solid var(--border); padding: 12px; overflow: hidden; @@ -1488,7 +1963,7 @@ select option { background: var(--bg-surface2); } background: transparent; border: 1px solid var(--border); color: var(--text-muted); - border-radius: var(--radius); + border-radius: 999px; padding: 4px 12px; font-family: var(--font-mono); font-size: 0.82rem; @@ -1501,31 +1976,204 @@ select option { background: var(--bg-surface2); } .page-info { color: var(--text-muted); font-size: 0.82rem; } /* ── Admin ─────────────────────────────────────────────────────────────────── */ +.admin-overview { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 18px; +} + +.admin-stat-card { + position: relative; + overflow: hidden; + background: + linear-gradient(180deg, rgba(31, 38, 59, 0.92), rgba(16, 20, 33, 0.96)), + radial-gradient(circle at top right, rgba(255, 214, 0, 0.12), transparent 55%); +} + +.admin-stat-value { + margin-top: 14px; + font-family: var(--font-heading); + font-size: clamp(2rem, 4vw, 2.8rem); + letter-spacing: 0.05em; + color: var(--accent); +} + +.admin-stat-meta { + margin-top: 6px; + color: var(--text-muted); + font-size: 0.9rem; +} + .admin-layout { display: grid; grid-template-columns: 1fr 2fr; gap: 16px; - max-width: 860px; + margin-bottom: 18px; } -.admin-user-list { display: flex; flex-direction: column; gap: 8px; } +.admin-spotlight { + padding: 16px 18px; + border-radius: var(--radius); + background: linear-gradient(135deg, rgba(255, 214, 0, 0.12), rgba(84, 118, 184, 0.08)); + border: 1px solid rgba(255, 214, 0, 0.16); +} + +.admin-spotlight-label, +.admin-user-meta, +.admin-activity-meta { + color: var(--text-muted); + font-size: 0.84rem; +} + +.admin-spotlight-label { + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.admin-spotlight-value { + margin-top: 8px; + font-family: var(--font-heading); + font-size: 1.7rem; + color: var(--accent); +} + +.admin-spotlight-meta { + margin-top: 6px; + color: var(--text-dim); + line-height: 1.5; +} + +.admin-users-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.admin-search { + width: min(260px, 100%); + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(11, 16, 28, 0.84); + color: var(--text); + padding: 11px 14px; + font-family: var(--font-mono); +} + +.admin-search:focus { + outline: none; + border-color: rgba(255, 214, 0, 0.48); + box-shadow: 0 0 0 3px rgba(255, 214, 0, 0.12); +} + +.admin-user-list { + display: flex; + flex-direction: column; + gap: 10px; +} .admin-user-row { display: flex; - align-items: center; - gap: 10px; - padding: 10px 14px; - background: var(--bg-surface2); + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + background: linear-gradient(180deg, rgba(19, 24, 39, 0.92), rgba(14, 18, 29, 0.98)); border-radius: var(--radius); border: 1px solid var(--border); - flex-wrap: wrap; - transition: border-color var(--transition); + transition: transform var(--transition), border-color var(--transition), background var(--transition); } -.admin-user-row:hover { border-color: rgba(65, 99, 156, 0.4); } +.admin-user-row:hover { + border-color: rgba(255, 214, 0, 0.22); + transform: translateY(-1px); +} + +.admin-user-main { + min-width: 0; + flex: 1; +} + +.admin-user-name-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} .user-name { flex: 1; font-family: var(--font-mono); font-size: 0.88rem; } +.admin-user-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + line-height: 1.45; +} + +.admin-user-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.admin-activity-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.admin-activity-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.admin-activity-item, +.admin-empty { + border-radius: var(--radius); + border: 1px solid var(--border); + background: rgba(13, 18, 31, 0.78); + padding: 14px 16px; +} + +.admin-activity-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; +} + +.admin-activity-head strong { + color: var(--text); + font-family: var(--font-heading); + letter-spacing: 0.04em; +} + +.admin-activity-head span, +.admin-activity-body { + color: var(--text-dim); +} + +.admin-activity-body { + margin-bottom: 8px; +} + +.admin-activity-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; +} + +.admin-empty { + color: var(--text-muted); + text-align: center; +} + .pw-display { margin-top: 12px; padding: 10px 14px; @@ -1619,6 +2267,9 @@ select option { background: var(--bg-surface2); } @media (max-width: 1024px) { .dashboard-grid { grid-template-columns: repeat(2, 1fr); } .mode-grid { grid-template-columns: repeat(2, 1fr); } + .login-shell { grid-template-columns: 1fr; } + .dashboard-briefing { grid-template-columns: 1fr; } + .briefing-actions { flex-direction: row; flex-wrap: wrap; } } @media (max-width: 768px) { @@ -1629,36 +2280,69 @@ select option { background: var(--bg-surface2); } .dashboard-grid { grid-template-columns: 1fr; } .dashboard-hero { flex-direction: column; gap: 16px; } - .hero-stats { justify-content: center; } + .hero-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; } .lobby-layout { grid-template-columns: 1fr; } .admin-layout { grid-template-columns: 1fr; } .match-sequences { grid-template-columns: 1fr; gap: 12px; } .mode-grid { grid-template-columns: repeat(2, 1fr); } + .login-intel { + padding: 28px 24px; + } + + .login-intel-title { + max-width: none; + font-size: clamp(2rem, 9vw, 3.2rem); + } + + .login-intel-grid { + grid-template-columns: 1fr; + } + .stratagem-name { font-size: 1.5rem; } - .arrow-key { width: 42px; height: 42px; font-size: 1.1rem; } + .arrow-key { width: 54px; height: 54px; font-size: 1.55rem; } .match-scoreboard { gap: 16px; padding: 12px; } .match-wins { font-size: 2.2rem; } .match-status-text { font-size: 1.4rem; } .practice-hud { gap: 16px; } .timer { font-size: 2rem; } .leaderboard-tabs { flex-wrap: wrap; } + .briefing-actions { flex-direction: column; } + .dpad-btn, .dpad-center { width: 64px; height: 64px; } + .arrow-sequence-duel .arrow-key { width: 50px; height: 50px; font-size: 1.35rem; } + .duel-progress { width: 100%; } + .practice-focus-shell, + .match-focus-shell { padding-bottom: 96px; } + #practice-dpad, + #match-dpad { + position: sticky; + bottom: 12px; + padding: 10px; + border-radius: 20px; + background: rgba(8, 11, 18, 0.92); + border: 1px solid rgba(255,255,255,0.08); + box-shadow: 0 18px 30px rgba(0,0,0,0.34); + } } @media (max-width: 480px) { .mode-grid { grid-template-columns: 1fr 1fr; gap: 8px; } .mode-card { padding: 14px 10px; } .mode-icon { font-size: 1.4rem; } - .hero-stats { gap: 16px; } + .hero-stats { gap: 10px; } .practice-hud { gap: 10px; } - .arrow-key { width: 38px; height: 38px; font-size: 1rem; } - .dpad-btn { width: 52px; height: 52px; } - .dpad-center { width: 52px; height: 52px; } + .arrow-key { width: 46px; height: 46px; font-size: 1.25rem; border-radius: 14px; } + .dpad-btn { width: 56px; height: 56px; } + .dpad-center { width: 56px; height: 56px; } .page-title { font-size: 1.3rem; } .match-wins { font-size: 1.8rem; } .match-status-text { font-size: 1.2rem; } .stratagem-icon-lg { width: 88px; height: 88px; } + .status-pill { width: 100%; justify-content: space-between; } + .login-box { padding: 32px 24px; } + .arrow-sequence-hero { padding: 12px; min-height: 78px; } + .arrow-sequence-duel .arrow-key { width: 44px; height: 44px; font-size: 1.1rem; } } /* ── Stratagem icons ─────────────────────────────────────────────────────── */ @@ -1675,15 +2359,14 @@ select option { background: var(--bg-surface2); } width: 110px; height: 110px; object-fit: contain; - filter: invert(1) sepia(1) saturate(4) hue-rotate(0deg) brightness(1.25) drop-shadow(0 0 8px rgba(255,210,0,0.4)); opacity: 1; - transition: transform 0.25s ease, filter 0.2s; + transition: transform 0.25s ease, opacity 0.2s ease; position: relative; } .stratagem-icon-lg.icon-complete { transform: scale(1.14); - filter: invert(1) sepia(1) saturate(6) hue-rotate(0deg) brightness(1.5) drop-shadow(0 0 14px rgba(255,210,0,0.7)); + opacity: 1; } .stratagem-icon-lg.icon-wrong { @@ -1699,7 +2382,6 @@ select option { background: var(--bg-surface2); } width: 52px; height: 52px; object-fit: contain; - filter: invert(1) sepia(1) saturate(4) hue-rotate(0deg) brightness(1.25) drop-shadow(0 0 5px rgba(255,210,0,0.3)); opacity: 1; } @@ -1707,7 +2389,6 @@ select option { background: var(--bg-surface2); } width: 22px; height: 22px; object-fit: contain; - filter: invert(1) sepia(1) saturate(3) hue-rotate(0deg) brightness(1.2); opacity: 0.9; vertical-align: middle; margin-right: 5px; @@ -1749,7 +2430,6 @@ select option { background: var(--bg-surface2); } width: 44px; height: 44px; object-fit: contain; - filter: invert(1) sepia(1) saturate(3) hue-rotate(0deg) brightness(1.15); } .queue-icon-fallback { @@ -1885,28 +2565,22 @@ select option { background: var(--bg-surface2); } word-break: break-all; } -/* ── Admin layout ────────────────────────────────────────────────────────── */ -.admin-layout { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; -} - -.admin-user-list { display: flex; flex-direction: column; gap: 8px; } - -.admin-user-row { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 0; - border-bottom: 1px solid var(--border-dim); -} - -.admin-user-row:last-child { border-bottom: none; } - @media (max-width: 768px) { .summary-grid { grid-template-columns: repeat(2, 1fr); } + .admin-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } .admin-layout { grid-template-columns: 1fr; } + .admin-activity-grid { grid-template-columns: 1fr; } + .admin-users-header { + align-items: stretch; + flex-direction: column; + } + .admin-search { width: 100%; } + .admin-user-row { + flex-direction: column; + align-items: stretch; + } + .admin-user-actions { justify-content: flex-start; } .stratagem-icon-lg { width: 88px; height: 88px; } .stratagem-queue { gap: 8px; } + .summary-insights { gap: 6px; } } diff --git a/scripts/download-icons-wiki.js b/scripts/download-icons-wiki.js new file mode 100644 index 0000000..9935ac6 --- /dev/null +++ b/scripts/download-icons-wiki.js @@ -0,0 +1,135 @@ +#!/usr/bin/env node +// Downloads stratagem icons from helldivers.wiki.gg +// Saves as public/icons/.svg (same filenames as before) + +import { createWriteStream, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import https from 'https'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUT_DIR = join(__dirname, '..', 'public', 'icons'); +const BASE_URL = 'https://helldivers.wiki.gg/images/'; + +mkdirSync(OUT_DIR, { recursive: true }); + +// Maps our stratagem slug → wiki filename (without _Stratagem_Icon.svg suffix) +const WIKI_MAP = { + reinforce: 'Reinforce', + resupply: 'Resupply', + sos_beacon: 'SOS_Beacon', + hellbomb: 'Hellbomb', + seaf_artillery: 'SEAF_Artillery', + upload_data: 'Start_Upload', + eagle_rearm: 'Eagle_Rearm', + prospecting_drill: 'Prospecting_Drill', + + orbital_gatling_barrage: 'Orbital_Gatling_Barrage', + orbital_airburst_strike: 'Orbital_Airburst_Strike', + orbital_120mm_he_barrage: 'Orbital_120mm_HE_Barrage', + orbital_380mm_he_barrage: 'Orbital_380mm_HE_Barrage', + orbital_walking_barrage: 'Orbital_Walking_Barrage', + orbital_laser: 'Orbital_Laser', + orbital_railcannon_strike: 'Orbital_Railcannon_Strike', + orbital_precision_strike: 'Orbital_Precision_Strike', + orbital_gas_strike: 'Orbital_Gas_Strike', + orbital_ems_strike: 'Orbital_EMS_Strike', + orbital_smoke_strike: 'Orbital_Smoke_Strike', + orbital_illumination_flare: 'Orbital_Illumination_Flare', + + eagle_strafing_run: 'Eagle_Strafing_Run', + eagle_airstrike: 'Eagle_Airstrike', + eagle_cluster_bomb: 'Eagle_Cluster_Bomb', + eagle_napalm_airstrike: 'Eagle_Napalm_Airstrike', + lift_850_jump_pack: 'Jump_Pack', + eagle_smoke_strike: 'Eagle_Smoke_Strike', + eagle_110mm_rocket_pods: 'Eagle_110mm_Rocket_Pods', + eagle_500kg_bomb: 'Eagle_500kg_Bomb', + + patriot_exosuit: 'Patriot_Exosuit', + emancipator_exosuit: 'Emancipator_Exosuit', + tesla_tower: 'Tesla_Tower', + shield_generator_relay: 'Shield_Generator_Relay', + + machine_gun: 'Machine_Gun', + anti_materiel_rifle: 'Anti-Materiel_Rifle', + stalwart: 'Stalwart', + expendable_anti_tank: 'Expendable_Anti-Tank', + recoilless_rifle: 'Recoilless_Rifle', + flamethrower: 'Flamethrower', + autocannon: 'Autocannon', + heavy_machine_gun: 'Heavy_Machine_Gun', + airburst_rocket_launcher: 'Airburst_Rocket_Launcher', + commando: 'Commando', + railgun: 'Railgun', + spear: 'Spear', + quasar_cannon: 'Quasar_Cannon', + arc_thrower: 'Arc_Thrower', + laser_cannon: 'Laser_Cannon', + grenade_launcher: 'Grenade_Launcher', + + supply_pack: 'Supply_Pack', + guard_dog_rover: 'Rover', + guard_dog: 'Guard_Dog', + ballistic_shield_backpack: 'Ballistic_Shield_Backpack', + shield_generator_pack: 'Shield_Generator_Pack', + directional_shield: 'Directional_Shield', + hmg_emplacement: 'HMG_Emplacement', + + anti_personnel_minefield: 'Anti-Personnel_Minefield', + incendiary_mines: 'Incendiary_Mines', + anti_tank_mines: 'Anti-Tank_Mines', + + machine_gun_sentry: 'Machine_Gun_Sentry', + gatling_sentry: 'Gatling_Sentry', + mortar_sentry: 'Mortar_Sentry', + autocannon_sentry: 'Autocannon_Sentry', + rocket_sentry: 'Rocket_Sentry', + ems_mortar_sentry: 'EMS_Mortar_Sentry', + + anti_tank_emplacement: 'Anti-Tank_Emplacement', +}; + +function download(url, dest) { + return new Promise((resolve, reject) => { + const file = createWriteStream(dest); + const request = (targetUrl) => { + https.get(targetUrl, { headers: { 'User-Agent': 'helldivers-trainer-bot/1.0' } }, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + file.destroy(); + return request(res.headers.location); + } + if (res.statusCode !== 200) { + file.destroy(); + return reject(new Error(`HTTP ${res.statusCode}`)); + } + res.pipe(file); + file.on('finish', () => file.close(resolve)); + }).on('error', reject); + }; + request(url); + }); +} + +async function main() { + const entries = Object.entries(WIKI_MAP); + let ok = 0, fail = 0; + + for (const [slug, wikiName] of entries) { + const filename = wikiName + '_Stratagem_Icon.svg'; + const url = BASE_URL + filename; + const dest = join(OUT_DIR, slug + '.svg'); + try { + await download(url, dest); + console.log(`✓ ${slug}`); + ok++; + } catch (err) { + console.error(`✗ ${slug} (${err.message})`); + fail++; + } + } + + console.log(`\nDone: ${ok} ok, ${fail} failed`); +} + +main(); diff --git a/scripts/download-icons.js b/scripts/download-icons.js index 101198b..d768e5c 100644 --- a/scripts/download-icons.js +++ b/scripts/download-icons.js @@ -119,12 +119,6 @@ async function downloadAll() { const slug = name.replace(/[^a-z0-9]/gi, '_').toLowerCase(); const outPath = path.join(ICONS_DIR, slug + '.svg'); - if (fs.existsSync(outPath)) { - console.log(` ✓ skip ${name}`); - ok++; - continue; - } - // Build GitHub raw URL (spaces → %20) const encoded = encodeURIComponent(folder) + '/' + encodeURIComponent(file + '.svg'); const url = `${BASE_URL}/${encoded}`; diff --git a/server.js b/server.js index 6ee3897..3545092 100644 --- a/server.js +++ b/server.js @@ -311,7 +311,22 @@ app.post('/api/change-password', requireAuth, async (req, res) => { // ── User management (admin) ─────────────────────────────────────────────────── app.get('/api/users', requireAuth, requireAdmin, (req, res) => { - const users = db.prepare('SELECT username, role, mustChange FROM users ORDER BY username').all(); + const users = db.prepare(` + SELECT + u.username, + u.role, + u.mustChange, + COALESCE(u.elo, 1000) AS elo, + COALESCE(ps.sessions, 0) AS sessions, + ps.lastPlayed AS lastPlayed + FROM users u + LEFT JOIN ( + SELECT username, COUNT(*) AS sessions, MAX(created_at) AS lastPlayed + FROM practice_sessions + GROUP BY username + ) ps ON ps.username = u.username + ORDER BY u.username + `).all(); res.json(users.map(u => ({ ...u, mustChange: u.mustChange === 1 }))); }); @@ -347,6 +362,95 @@ app.delete('/api/users/:username', requireAuth, requireAdmin, (req, res) => { res.json({ ok: true }); }); +app.post('/api/users/:username/reset-password', requireAuth, requireAdmin, async (req, res) => { + const { username } = req.params; + const user = db.prepare('SELECT username FROM users WHERE username = ?').get(username); + if (!user) return res.status(404).json({ error: 'User not found' }); + + const tempPw = crypto.randomBytes(6).toString('hex'); + const hash = await bcrypt.hash(tempPw, 12); + db.prepare('UPDATE users SET hash = ?, mustChange = 1 WHERE username = ?').run(hash, username); + res.json({ ok: true, tempPassword: tempPw }); +}); + +app.patch('/api/users/:username', requireAuth, requireAdmin, (req, res) => { + const { username } = req.params; + const { role } = req.body || {}; + if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Valid role required' }); + + const user = db.prepare('SELECT username, role FROM users WHERE username = ?').get(username); + if (!user) return res.status(404).json({ error: 'User not found' }); + + if (username === req.session.user && role !== 'admin') { + return res.status(400).json({ error: 'You cannot demote yourself' }); + } + + if (user.role === 'admin' && role !== 'admin') { + const adminCount = db.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'").get().c; + if (adminCount <= 1) return res.status(400).json({ error: 'Cannot demote the last admin' }); + } + + db.prepare('UPDATE users SET role = ? WHERE username = ?').run(role, username); + res.json({ ok: true }); +}); + +app.get('/api/admin/overview', requireAuth, requireAdmin, (req, res) => { + const totals = db.prepare(` + SELECT + COUNT(*) AS users, + SUM(CASE WHEN role = 'admin' THEN 1 ELSE 0 END) AS admins, + SUM(CASE WHEN mustChange = 1 THEN 1 ELSE 0 END) AS tempPasswords + FROM users + `).get(); + + const activity = db.prepare(` + SELECT + (SELECT COUNT(*) FROM practice_sessions) AS practiceSessions, + (SELECT COUNT(*) FROM matches) AS matches, + (SELECT COALESCE(SUM(score), 0) FROM practice_sessions) AS totalPracticeScore + `).get(); + + const topUser = db.prepare(` + SELECT username, COUNT(*) AS sessions, COALESCE(SUM(score), 0) AS totalScore + FROM practice_sessions + GROUP BY username + ORDER BY totalScore DESC, sessions DESC + LIMIT 1 + `).get(); + + const latestActivity = db.prepare(` + SELECT username, stratagem, score, created_at + FROM practice_sessions + ORDER BY created_at DESC + LIMIT 1 + `).get(); + + res.json({ + totals, + activity, + topUser: topUser || null, + latestActivity: latestActivity || null, + }); +}); + +app.get('/api/admin/activity', requireAuth, requireAdmin, (req, res) => { + const practice = db.prepare(` + SELECT username, stratagem, score, mode, created_at + FROM practice_sessions + ORDER BY created_at DESC + LIMIT 10 + `).all(); + + const matches = db.prepare(` + SELECT winner, loser, winner_rounds, loser_rounds, created_at + FROM matches + ORDER BY created_at DESC + LIMIT 10 + `).all(); + + res.json({ practice, matches }); +}); + // ── Leaderboard cache ───────────────────────────────────────────────────────── let lbCache = null; let lbCacheTime = 0;