| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { Auth } from '../auth/Auth.js'; |
|
|
| export class AdminUI { |
| constructor({ onSwitchToGame } = {}) { |
| this._root = null; |
| this._mode = 'modal'; |
| this._onSwitchToGame = onSwitchToGame; |
| } |
|
|
| |
| open() { |
| if (!Auth.isAdmin()) { |
| alert('Admin access only. Sign in with an admin account first.'); |
| return false; |
| } |
| return this._show('modal'); |
| } |
|
|
| |
| 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 = ` |
| <div class="admin-card${isPage ? ' page' : ''}"> |
| <header> |
| <h2>⚙ Admin Dashboard</h2> |
| <div class="admin-current">${me ? `Signed in as <b>${esc(me.email)}</b>` : ''}</div> |
| <div class="admin-head-actions"> |
| ${isPage |
| ? `<button class="btn admin-btn ghost" id="admGame" title="Open the game view">🎮 Game</button> |
| <button class="btn admin-btn ghost" id="admSignOut" title="Sign out">⎋ Sign out</button>` |
| : `<button class="close" id="admClose" title="Close (Esc)">×</button>`} |
| </div> |
| </header> |
| <section class="admin-stats" id="admStats"></section> |
| <section class="admin-actions"> |
| <button id="admNew" class="btn admin-btn primary">+ New user</button> |
| <button id="admExport" class="btn admin-btn">⬇ Export DB</button> |
| <button id="admImport" class="btn admin-btn">⬆ Import DB</button> |
| <input id="admImportFile" type="file" accept="application/json" style="display:none"> |
| <button id="admRefresh" class="btn admin-btn ghost">↻ Refresh</button> |
| <span class="admin-spacer"></span> |
| <input type="search" id="admSearch" class="admin-search" placeholder="Search email or name…"> |
| </section> |
| <section class="admin-table-wrap"> |
| <table class="admin-table"> |
| <thead><tr> |
| <th>Email</th><th>Name</th><th>Role</th><th>Grade</th> |
| <th>⭐ Stars</th><th>Mastered</th><th>Created</th><th>Age</th><th>Actions</th> |
| </tr></thead> |
| <tbody id="admBody"></tbody> |
| </table> |
| <div class="admin-empty" id="admEmpty" style="display:none">No users match.</div> |
| </section> |
| <footer class="admin-footer"> |
| <span>ECHO 3D · Admin</span> |
| <span>Local-only — stored in this browser's localStorage / IndexedDB</span> |
| </footer> |
| </div>`; |
|
|
| 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; |
|
|
| |
| 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())} |
| `; |
|
|
| |
| 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 = ` |
| <td class="email"> |
| <span class="${u.online ? 'dot online' : 'dot'}" title="${u.online ? 'online' : 'offline'}"></span> |
| ${esc(u.email)}${isMe ? ' <em>(you)</em>' : ''} |
| </td> |
| <td>${esc(u.name || '—')}</td> |
| <td><span class="role ${u.isAdmin ? 'role-admin' : 'role-user'}">${u.isAdmin ? 'admin' : 'user'}</span></td> |
| <td>${u.grade || 1}</td> |
| <td>${(u.stars || 0).toLocaleString()}</td> |
| <td>${(u.mastered || 0).toLocaleString()}</td> |
| <td>${u.created ? new Date(u.created).toLocaleDateString() : '—'}</td> |
| <td>${ageDays === null ? '—' : (ageDays === 0 ? 'today' : `${ageDays}d`)}</td> |
| <td class="acts"> |
| <button data-act="reset" data-email="${esc(u.email)}" title="Reset progress">↺</button> |
| <button data-act="admin" data-email="${esc(u.email)}" title="${u.isAdmin ? 'Revoke admin' : 'Make admin'}">${u.isAdmin ? '◐' : '◑'}</button> |
| <button data-act="delete" data-email="${esc(u.email)}" title="Delete user" ${isMe ? 'disabled' : ''}>🗑</button> |
| </td>`; |
| 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 `<div class="admin-stat ${extraCls}"><div class="n">${value}</div><div class="l">${label}</div></div>`; |
| } |
| function esc(s) { |
| return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); |
| } |
|
|