/** * AdminUI โ€” admin dashboard. * * Two display modes: * - modal: centered overlay above the game (opened via admin chip / ?admin URL) * - page : fullscreen primary view (auto-shown when an admin signs in) * * Lets the admin view DB stats, every registered user, and: * - reset a user's progress * - toggle the admin flag * - delete a user (except self) * - export / import the user database as JSON * - search by email or name * - switch to the game view (page mode only) */ import { Auth } from '../auth/Auth.js'; export class AdminUI { constructor({ onSwitchToGame } = {}) { this._root = null; this._mode = 'modal'; this._onSwitchToGame = onSwitchToGame; } /** Centred modal over the game (admin chip / ?admin URL). */ open() { if (!Auth.isAdmin()) { alert('Admin access only. Sign in with an admin account first.'); return false; } return this._show('modal'); } /** Fullscreen primary view โ€” admin landing page after login. */ openAsPage() { if (!Auth.isAdmin()) return false; return this._show('page'); } _show(mode) { this._mode = mode; if (this._root) { this._root.remove(); this._root = null; } this._root = this._build(); document.body.appendChild(this._root); this.render(); return true; } close() { if (this._root) { this._root.remove(); this._root = null; } } _build() { const isPage = this._mode === 'page'; const r = document.createElement('div'); r.id = 'adminPanel'; r.dataset.mode = this._mode; const me = Auth.current(); r.innerHTML = `

โš™ Admin Dashboard

${me ? `Signed in as ${esc(me.email)}` : ''}
${isPage ? ` ` : ``}
EmailNameRoleGrade โญ StarsMasteredCreatedAgeActions
`; r.style.cssText = isPage ? `position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:99999; display:flex;align-items:stretch;justify-content:center; background:radial-gradient(ellipse at 50% 30%,#1a2440 0%,#070912 70%);padding:24px` : `position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:99999; display:flex;align-items:center;justify-content:center; background:rgba(8,10,18,.85);backdrop-filter:blur(6px);padding:20px`; if (isPage) { r.querySelector('#admGame').onclick = () => this._switchToGame(); r.querySelector('#admSignOut').onclick = () => this._signOut(); } else { r.querySelector('#admClose').onclick = () => this.close(); } r.querySelector('#admNew').onclick = () => this._createUserPrompt(); r.querySelector('#admRefresh').onclick = () => this.render(); r.querySelector('#admExport').onclick = () => this._exportDb(); r.querySelector('#admImport').onclick = () => r.querySelector('#admImportFile').click(); r.querySelector('#admImportFile').onchange = (e) => this._importDb(e.target.files[0]); r.querySelector('#admSearch').addEventListener('input', () => this.render()); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this._mode === 'modal' && this._root) this.close(); }); return r; } _switchToGame() { if (!this._root) return; this._root.remove(); this._root = null; if (this._onSwitchToGame) this._onSwitchToGame(); } async _createUserPrompt() { const email = prompt('New user email:'); if (!email) return; const name = prompt('Display name (optional):', email.split('@')[0]); const pwd = prompt('Password (6+ chars):'); if (!pwd) return; const isAdmin = confirm('Make this user an admin?'); try { await Auth.adminCreateUser({ email, pwd, name, isAdmin }); alert(`Created ${email}${isAdmin ? ' (admin)' : ''}`); this.render(); } catch (e) { alert('Could not create user: ' + e.message); } } async _signOut() { if (!confirm('Sign out of the admin dashboard?')) return; await Auth.logout().catch(() => {}); location.reload(); } async render() { if (!this._root) return; let allUsers; try { allUsers = await Auth.adminListUsers(); } catch (e) { allUsers = Auth.allUsers(); } const me = Auth.current()?.email; // Stats โ€” always over the full database, regardless of search filter. const totalStars = allUsers.reduce((s, u) => s + (u.stars || 0), 0); const totalMastered = allUsers.reduce((s, u) => s + (u.mastered || 0), 0); const admins = allUsers.filter(u => u.isAdmin).length; const onlineCount = allUsers.filter(u => u.online).length; this._root.querySelector('#admStats').innerHTML = ` ${statCard('Total users', allUsers.length)} ${statCard('Online now', onlineCount, 'online-now')} ${statCard('Admins', admins)} ${statCard('โญ Stars', totalStars.toLocaleString())} ${statCard('Mastered', totalMastered.toLocaleString())} `; // Filter + sort. const q = (this._root.querySelector('#admSearch').value || '').toLowerCase().trim(); const users = allUsers .filter(u => !q || u.email.toLowerCase().includes(q) || (u.name || '').toLowerCase().includes(q)) .sort((a, b) => (b.stars || 0) - (a.stars || 0)); const body = this._root.querySelector('#admBody'); const empty = this._root.querySelector('#admEmpty'); body.innerHTML = ''; empty.style.display = users.length ? 'none' : 'block'; const now = Date.now(); for (const u of users) { const tr = document.createElement('tr'); const isMe = u.email === me; const ageDays = u.created ? Math.max(0, Math.floor((now - u.created) / 86400000)) : null; tr.innerHTML = ` ${esc(u.email)}${isMe ? ' (you)' : ''} ${esc(u.name || 'โ€”')} ${u.isAdmin ? 'admin' : 'user'} ${u.grade || 1} ${(u.stars || 0).toLocaleString()} ${(u.mastered || 0).toLocaleString()} ${u.created ? new Date(u.created).toLocaleDateString() : 'โ€”'} ${ageDays === null ? 'โ€”' : (ageDays === 0 ? 'today' : `${ageDays}d`)} `; body.appendChild(tr); } body.onclick = async (e) => { const b = e.target.closest('button[data-act]'); if (!b) return; const { act, email } = b.dataset; try { if (act === 'reset') { if (confirm(`Reset ${email}'s progress to zero?`)) await Auth.adminResetProgress(email); } if (act === 'admin') { const cur = allUsers.find(u => u.email === email)?.isAdmin; await Auth.adminSetAdmin(email, !cur); } if (act === 'delete') { if (confirm(`Delete user ${email}? This cannot be undone.`)) await Auth.adminDeleteUser(email); } this.render(); } catch (err) { alert(err.message); } }; } async _exportDb() { try { const json = await Auth.adminExport(); const blob = new Blob([json], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `echo-users-${new Date().toISOString().slice(0,10)}.json`; a.click(); URL.revokeObjectURL(a.href); } catch (e) { alert('Export failed: ' + e.message); } } _importDb(file) { if (!file) return; if (!confirm('Overwrite the user database with the contents of this file?')) return; const reader = new FileReader(); reader.onload = async () => { try { const n = await Auth.adminImport(reader.result); alert(`Imported ${n} users.`); this.render(); } catch (e) { alert('Import failed: ' + e.message); } }; reader.readAsText(file); } } function statCard(label, value, extraCls = '') { return `
${value}
${label}
`; } function esc(s) { return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); }