File size: 10,452 Bytes
173e683 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | /**
* 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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
}
|