Echo / src /ui /AdminUI.js
mlmihjaz's picture
Initial commit: ECHO 3D vocab game with Node+SQLite backend, admin dashboard, Docker deploy
173e683
Raw
History Blame Contribute Delete
10.5 kB
/**
* 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 = `
<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;
// 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 = `
<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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}