Admin interface

This commit is contained in:
2026-10-22 11:26:42 -04:00
parent 2a2d976013
commit 3b81cae024
6 changed files with 583 additions and 9 deletions
+105 -1
View File
@@ -12,6 +12,9 @@ let currentShareText = "";
let selectedSection = null;
let chatSignature = null;
let chatLoading = false;
let isAdmin = false;
let adminSnapshot = null;
let adminBusy = false;
const PRESENCE_KEY = "thebutton-share-presence";
let sharePresence = localStorage.getItem(PRESENCE_KEY) !== "false";
let presenceTab = sessionStorage.getItem("thebutton-presence-tab");
@@ -208,9 +211,11 @@ window.addEventListener("storage", (event) => {
}
if (event.key === PRESENCE_KEY || event.key === TOKEN_KEY) {
sharePresence = localStorage.getItem(PRESENCE_KEY) !== "false";
if (event.key === TOKEN_KEY) isAdmin = false;
token = localStorage.getItem(TOKEN_KEY);
updateButton();
syncPresence();
if (event.key === TOKEN_KEY && token) loadMe();
}
});
@@ -221,6 +226,7 @@ function renderSections() {
"seasons-section": true,
"chat-section": true,
"alerts-section": true,
"admin-section": !!token && isAdmin,
"stats-section": statsLoaded,
rules: true,
"share-controls": !!currentShareText,
@@ -242,6 +248,7 @@ function renderSections() {
$("chat-login").hidden = !!token;
$("section-hint").hidden = selectedSection !== null;
$("presence-opt-in").checked = sharePresence;
if (!token || !isAdmin) clearAdminData();
}
$("section-nav").addEventListener("click", (event) => {
@@ -253,6 +260,7 @@ $("section-nav").addEventListener("click", (event) => {
if (selectedSection === "leaderboard-section") loadLeaderboard();
if (selectedSection === "chat-section") loadChat();
if (selectedSection === "seasons-section") loadSeasons();
if (selectedSection === "admin-section") loadAdmin();
syncPresence();
});
@@ -487,8 +495,11 @@ function renderAchievements(achievements) {
async function loadMe() {
if (!token) return;
const requestedToken = token;
try {
const me = await api("GET", "/api/me");
if (token !== requestedToken) return;
isAdmin = me.is_admin === true;
$("my-name").textContent = me.name;
$("my-points").textContent = me.total_points.toLocaleString();
$("my-presses").textContent = me.press_count;
@@ -531,12 +542,105 @@ async function loadMe() {
} catch (e) {
if (e.status === 401) {
token = null;
isAdmin = false;
localStorage.removeItem(TOKEN_KEY);
updateButton();
}
}
}
function clearAdminData() {
adminSnapshot = null;
for (const id of ["admin-overview", "admin-players", "admin-messages", "admin-audit"]) $(id).replaceChildren();
$("admin-status").textContent = "";
$("admin-error").textContent = "";
$("admin-player-filter").value = "";
}
function adminButton(text, action, target, value, extra = {}) {
const button = el("button", text, action === "delete_player" || action === "delete_message" ? "danger-button" : "");
button.type = "button";
button.disabled = adminBusy;
button.addEventListener("click", async () => {
const confirmation = action === "delete_player"
? `Delete account ${extra.name}? Login credentials will be removed, messages anonymized, and historical scores kept as Deleted player. This cannot be undone.`
: action === "delete_message" ? "Permanently remove this message from chat?"
: `${text} for ${extra.name}? Existing sessions will be revoked and the player must log in again.`;
if (adminBusy || !window.confirm(confirmation)) return;
adminBusy = true;
$("admin-error").textContent = "";
document.querySelectorAll("#admin-section button").forEach((b) => { b.disabled = true; });
try {
await api("POST", "/api/admin/action", { action, target_id: String(target),
...(value === undefined ? {} : { value }), ...(extra.at === undefined ? {} : { at: extra.at }) });
$("admin-status").textContent = "Action completed.";
chatSignature = null;
await loadAdmin();
await loadMe();
} catch (error) {
$("admin-error").textContent = error.message;
if (error.status === 401 || error.status === 403) { isAdmin = false; renderSections(); }
} finally {
adminBusy = false;
document.querySelectorAll("#admin-section button").forEach((b) => { b.disabled = false; });
renderAdminPlayers();
}
});
return button;
}
function renderAdminPlayers() {
if (!adminSnapshot) return;
const query = $("admin-player-filter").value.toLowerCase();
const players = adminSnapshot.players.filter((p) => p.name.toLowerCase().includes(query));
$("admin-players").replaceChildren(...players.map((player) => {
const item = el("li", undefined, "admin-card");
item.append(el("strong", player.name), el("p", `${player.is_admin ? "Admin" : "Player"} / ${player.is_banned ? "Banned" : "Active"} / ${player.total_points} points / ${player.press_count} presses / ${player.sessions} sessions`, "fine-print"));
const actions = el("div", undefined, "form-actions");
if (player.id === adminSnapshot.actor_id) {
actions.append(el("span", "Your account (protected)", "site-badge"));
} else {
actions.append(adminButton(player.is_admin ? "Remove admin" : "Make admin", "set_admin", player.id, !player.is_admin, { name: player.name }),
adminButton(player.is_banned ? "Unban" : "Ban", "set_banned", player.id, !player.is_banned, { name: player.name }),
adminButton("Delete account", "delete_player", player.id, undefined, { name: player.name }));
}
item.append(actions);
return item;
}));
}
async function loadAdmin() {
if (!token || !isAdmin) return;
const requestedToken = token;
try {
const data = await api("GET", "/api/admin");
if (token !== requestedToken || !isAdmin) return;
adminSnapshot = data;
$("admin-overview").replaceChildren(...[
[data.season.name, data.game.ended ? "Season finished" : "Season active"],
[data.players.length.toLocaleString(), "Player accounts"],
[data.game.total_presses.toLocaleString(), "Season presses"],
[data.game.stored_chat_messages.toLocaleString(), "Stored chat rows"],
].map(([value, label]) => { const card = el("div", undefined, "metric-card"); card.append(el("span", label), el("strong", value)); return card; }));
renderAdminPlayers();
$("admin-messages").replaceChildren(...data.messages.slice().reverse().map((message) => {
const item = el("li", undefined, "admin-card");
item.append(el("strong", message.name), el("small", `${new Date(message.at).toLocaleString()} / ${message.kind}`, "fine-print"),
el("p", message.message), adminButton("Remove message", "delete_message", message.id, undefined, { at: message.at }));
return item;
}));
fillTable($("admin-audit"), ["When", "Actor", "Action", "Target"], data.audit.map((entry) => [
new Date(entry.at).toLocaleString(), entry.actor_id, entry.action, entry.target_id,
]));
$("admin-status").textContent = `Last refreshed ${new Date().toLocaleTimeString()}. Refresh manually to keep controls stable.`;
} catch (error) {
$("admin-error").textContent = error.message;
if (error.status === 401 || error.status === 403) { isAdmin = false; renderSections(); }
}
}
$("admin-refresh").addEventListener("click", () => { if (!adminBusy) loadAdmin(); });
$("admin-player-filter").addEventListener("input", renderAdminPlayers);
function seasonDuration(ms) {
const days = Math.floor(ms / 86400000);
return `${days ? `${days}d ` : ""}${formatMs(ms % 86400000).slice(0, -3)}`;
@@ -602,7 +706,7 @@ async function loadChat() {
chatLoading = true;
try {
const { messages } = await api("GET", "/api/chat");
const signature = messages.map((message) => message.id).join(",");
const signature = JSON.stringify(messages);
const list = $("chat-messages");
if (signature !== chatSignature) {
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 40;
+24
View File
@@ -46,6 +46,7 @@
<button type="button" data-section="seasons-section" aria-controls="seasons-section" aria-expanded="false">Seasons</button>
<button type="button" data-section="chat-section" aria-controls="chat-section" aria-expanded="false">Chat</button>
<button type="button" data-section="alerts-section" aria-controls="alerts-section" aria-expanded="false">Alerts</button>
<button type="button" data-section="admin-section" aria-controls="admin-section" aria-expanded="false" hidden>Admin</button>
<button type="button" data-section="stats-section" aria-controls="stats-section" aria-expanded="false" hidden>Final stats</button>
<button type="button" data-section="rules" aria-controls="rules" aria-expanded="false">Rules</button>
<button type="button" data-section="share-controls" aria-controls="share-controls" aria-expanded="false" hidden>Share</button>
@@ -61,6 +62,29 @@
<p class="welcome-hint">Choose a section above to join in, compare scores, or say hello.</p>
</div>
<section id="admin-section" data-panel hidden aria-labelledby="admin-heading">
<header class="panel-header"><div class="header-topline"><p class="eyebrow">KEEP THE EXPERIMENT WELCOMING</p><span class="site-badge">Administrator</span></div>
<h2 id="admin-heading">Control room.</h2>
<p class="panel-description">Manage player access and keep the conversation on track. Every action is recorded.</p>
</header>
<div class="panel-body">
<div class="form-actions"><button id="admin-refresh" type="button">Refresh dashboard</button></div>
<p id="admin-status" role="status" class="fine-print"></p>
<p id="admin-error" role="status" class="error"></p>
<div id="admin-overview" class="score-grid"></div>
<h3 class="subheading">Players</h3>
<label class="field" for="admin-player-filter"><span>Filter by player name</span><input id="admin-player-filter" type="search" placeholder="Find a player" autocomplete="off"></label>
<p class="fine-print">Role changes and bans revoke the player's sessions. You cannot remove your own access or the last active administrator. Delete account removes credentials and anonymizes the player; historical scores stay.</p>
<ul id="admin-players" class="admin-list" aria-label="Player administration"></ul>
<h3 class="subheading">Recent chat</h3>
<p class="fine-print">Latest 100 visible messages, including automatic announcements. Removal is permanent; it does not undo presses or milestone unlocks.</p>
<ul id="admin-messages" class="admin-list" aria-label="Chat moderation"></ul>
<h3 class="subheading">Recent admin actions</h3>
<p class="fine-print">Most recent 50 actions. IDs are internal account/message identifiers; no passwords or tokens are recorded.</p>
<div class="table-scroll"><table id="admin-audit" aria-label="Administrator audit log"></table></div>
</div>
</section>
<section id="seasons-section" data-panel hidden aria-labelledby="seasons-heading">
<header class="panel-header"><p class="eyebrow">A NEW CHAPTER, FOUR TIMES A YEAR</p>
<h2 id="seasons-heading">The seasonal leaderboard.</h2>
+11
View File
@@ -321,6 +321,17 @@ button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visibl
.error { color: var(--red); }
.panel-body > .error { color: var(--red); }
.error:empty { display: none; }
.admin-list { list-style: none; display: grid; gap: 0.75rem; padding: 0; max-height: 520px; overflow-y: auto; }
.admin-card { background: var(--base); border: 1px solid var(--surface); border-radius: 12px; padding: 1rem; min-width: 0; overflow-wrap: anywhere; }
.admin-card > strong { color: var(--lavender); font-size: 0.9rem; }
.admin-card > small { display: block; margin-top: 0.3rem; }
.admin-card > p { font-size: 0.85rem; }
.admin-card .form-actions button { flex: 0 1 auto; font-size: 0.75rem; }
button.danger-button:not(#the-button) { color: var(--red); border-color: #f38ba840; background: #f38ba808; }
button.danger-button:not(#the-button):hover { background: #f38ba820; }
#admin-overview { margin-top: 1rem; }
#admin-overview .metric-card strong { font-size: 1.2rem; }
#admin-audit td { max-width: 160px; overflow-wrap: anywhere; }
#alerts-form { display: grid; justify-content: stretch; gap: 1rem; max-width: 460px; }
.alert-option { display: flex; align-items: center; gap: 0.65rem; color: var(--text); font-size: 0.85rem; cursor: pointer; }
.alert-option input { width: 18px; height: 18px; min-height: 0; padding: 0; margin: 0; accent-color: var(--mauve); flex-shrink: 0; }