simpleadmin-web/internal/web/assets/public.js
Astra 17232bd5a2 Staff push notifications, and revalidate embedded assets
Notifications: staff turn them on per device from a new Notifications page,
pick each type (bans, gags, mutes and silences added or lifted, server down
and back up) and quiet hours. A watcher polls SimpleAdmin's tables, so in-game
penalties are sent too. Web Push with VAPID and RFC 8291 encryption in
internal/push, standard library only. Tables in sql/push.sql, created at
startup; the VAPID key is made on first start and kept in saw_settings.
Penalty notifications link to the public list searching for the player
(#bans?q=<steamid64>). The Android app delegates notifications (versionCode 3).

Caching: pages, scripts and styles are served with Cache-Control: no-cache
and an ETag, so a deploy shows up at once instead of after Cloudflare's
default 4-hour browser cache.
2026-10-03 13:49:40 +01:00

314 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Public, read-only site: the live server, bans, gags/mutes, and staff with their rank changes. The
// API leaves out who issued penalties and IPs; nothing here can change anything.
const PAGES = [
{ id: "server", label: "Server" },
{ id: "bans", label: "Bans" },
{ id: "comms", label: "Gags & mutes" },
{ id: "staff", label: "Staff" },
];
const state = { page: "server", banFilter: "active", commFilter: "active", query: "", num: 1 };
function siteName(title) {
const i = title.indexOf(".");
return i > 0 ? `${esc(title.slice(0, i))}<span>.</span>${esc(title.slice(i + 1))}` : esc(title);
}
/* ---------- Server ---------- */
async function server() {
const v = $("#view");
let s;
try { s = await api("/api/public/server"); } catch (err) { v.innerHTML = errorBox(err); return; }
if (state.page !== "server") return;
if (!s.online) {
v.innerHTML = `<div class="live"><div><p class="live-host">The server is offline or restarting.</p><div class="live-map">Offline</div></div></div>`;
return;
}
const side = (team, name, score) => {
const list = s.players.filter((p) => p.team === team).sort((a, b) => b.kills - a.kills);
const humans = list.filter((p) => !p.bot).length;
return `<section class="panel side ${team === 3 ? "ct" : "t"}">
<div class="panel-head">
<div><h2>${name}</h2><p>${humans} ${humans === 1 ? "player" : "players"}${list.length > humans ? `, ${list.length - humans} ${list.length - humans === 1 ? "bot" : "bots"}` : ""}</p></div>
${s.warmup ? '<span class="side-warmup">Warmup</span>' : `<div class="side-score"><b>${score}</b><small>${score === 1 ? "round" : "rounds"} won</small></div>`}
</div>
${list.length ? `<table>
<thead><tr><th>Player</th><th class="num">Kills</th><th class="num">Deaths</th><th class="num hide-sm">Ping</th></tr></thead>
<tbody>
${list.map((p) => `<tr ${p.bot ? "" : `class="clickable" data-player="${esc(p.steamid)}"`}>
<td><div class="who"><div><b>${esc(p.name)}</b>${p.verified ? ` ${VERIFIED_TAG}` : ""}${p.bot ? '<span class="sid">Bot</span>' : p.rank ? rankTag(p.rank) : ""}</div></div></td>
<td class="num">${p.kills}</td>
<td class="num">${p.deaths}</td>
<td class="num hide-sm muted">${p.bot ? "" : p.ping}</td>
</tr>`).join("")}
</tbody>
</table>` : '<div class="empty">Nobody on this team.</div>'}
</section>`;
};
const spectators = s.players.filter((p) => p.team !== 2 && p.team !== 3 && !p.bot);
v.innerHTML = `
<div class="live">
<div>
<p class="live-host"><span class="dot"></span>${esc(s.hostname)}${s.warmup ? ", warmup" : ""}</p>
<div class="live-map">${esc(s.map)}</div>
</div>
<div class="live-stats">
<div><b>${s.count}<span class="faint">/${s.maxPlayers}</span></b><small>Players</small></div>
<div><b><span class="team t score">${s.scoreT}</span> <span class="faint">:</span> <span class="team ct score">${s.scoreCt}</span></b><small>${s.warmup ? "Warmup, no rounds yet" : "Rounds won, T vs CT"}</small></div>
<div><b>${s.staffOnline}</b><small>Staff online</small></div>
</div>
${s.address ? `<div class="live-join">
<a class="btn primary" href="steam://connect/${esc(s.address)}">Join server</a>
<small class="muted">or type <b>connect ${esc(s.address)}</b> in the console</small>
</div>` : ""}
</div>
<div class="sides">
${side(3, "Counter-Terrorists", s.scoreCt)}
${side(2, "Terrorists", s.scoreT)}
</div>
${spectators.length ? `<p class="muted" style="margin-top:16px">Spectating: ${spectators.map((p) => esc(p.name)).join(", ")}</p>` : ""}`;
}
/* ---------- Bans and comms ---------- */
function publicTable(rows, kind) {
if (!rows.length) {
return `<div class="empty">${state.query ? `Nobody matches "${esc(state.query)}". Try the full SteamID64.` : "Nothing in this view."}</div>`;
}
return `<table>
<thead><tr>
<th>Player</th>
${kind === "comm" ? "<th>Type</th>" : ""}
<th>Reason</th>
<th>Length</th>
<th class="hide-sm">Issued</th>
<th>Status</th>
</tr></thead>
<tbody>
${rows.map((p) => `<tr ${p.steamid ? `class="clickable" data-player="${esc(p.steamid)}"` : ""}>
<td><div class="who"><div><b>${esc(p.name || "Unknown")}</b><span class="sid">${esc(p.steamid)}</span></div></div></td>
${kind === "comm" ? `<td><span class="tag amber" title="${COMM_HELP[p.type]}">${COMM_LABEL[p.type]}</span></td>` : ""}
<td>${esc(p.reason)}</td>
<td>${termCell(p)}</td>
<td class="hide-sm">${fmtDate(p.created)}</td>
<td>${statusTag(p)}</td>
</tr>`).join("")}
</tbody>
</table>`;
}
async function loadResults() {
const box = $("#results");
if (!box) return;
const bans = state.page === "bans";
const path = bans ? "/api/public/bans" : "/api/public/comms";
const status = bans ? state.banFilter : state.commFilter;
const want = `${state.page}|${status}|${state.query}|${state.num}`;
loadResults.want = want;
try {
const list = await api(path + qs({ status, q: state.query, page: state.num }));
if (loadResults.want !== want) return;
box.innerHTML = publicTable(list.items, bans ? "ban" : "comm") + pager(list);
const lead = $("#lead");
if (lead && bans) lead.textContent = `${list.active} ${list.active === 1 ? "player is" : "players are"} banned right now. Search for a name or SteamID64 to check a ban and when it ends.`;
} catch (err) {
box.innerHTML = errorBox(err);
}
}
function bans() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Bans</h1><p class="sub" id="lead">Search for a name or SteamID64 to check a ban and when it ends.</p></div>
</div>
<section class="panel">
<div class="toolbar">
${searchBox("Search by name, SteamID64 or reason", state.query)}
${segmented("banFilter", state.banFilter, [["active", "Active"], ["expired", "Expired"], ["lifted", "Lifted"], ["all", "All"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
loadResults();
}
function comms() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Gags & mutes</h1><p class="sub">A gag blocks text chat, a mute blocks voice, and a silence blocks both. Blocks carry over between maps and reconnects.</p></div>
</div>
<section class="panel">
<div class="toolbar">
${searchBox("Search by name, SteamID64 or reason", state.query)}
${segmented("commFilter", state.commFilter, [["active", "Active"], ["GAG", "Gags"], ["MUTE", "Mutes"], ["SILENCE", "Silences"], ["expired", "Expired"], ["all", "All"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
loadResults();
}
/* ---------- Staff ---------- */
async function staff() {
const v = $("#view");
v.innerHTML = loading();
let data;
try { data = await api("/api/public/staff"); } catch (err) { v.innerHTML = errorBox(err); return; }
if (state.page !== "staff") return;
const rankRow = (g) => `<div class="crew-row" style="--c:${esc(g.color)}">
<div class="crew-rank"><h2><span class="rank" style="font:inherit"><i></i>${esc(g.label)}</span></h2>${g.about ? `<p>${esc(g.about)}</p>` : ""}</div>
<ul class="crew">
${g.members.map((p) => `<li>
<a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">
${avatar(p.steamid, p.name)}
<span><b>${esc(p.name)}</b><small>${p.online ? '<span class="dot"></span>On the server' : "Steam profile"}</small></span>
</a>
</li>`).join("")}
</ul>
</div>`;
const staffGroups = data.groups.filter((g) => !g.supporter);
const supporters = data.groups.filter((g) => g.supporter);
const online = data.staffOnline;
v.innerHTML = `
<div class="page-head">
<div><h1>Staff</h1><p class="sub">${online ? `${online} staff ${online === 1 ? "is" : "are"} on the server right now.` : "No staff are on the server right now."}</p></div>
</div>
${staffGroups.length ? `<section class="panel crew-panel">${staffGroups.map(rankRow).join("")}</section>` : '<div class="empty">No staff are listed yet.</div>'}
${supporters.length ? `<h2 class="crew-sep">Supporters</h2><section class="panel crew-panel">${supporters.map(rankRow).join("")}</section>` : ""}
<h2 class="crew-sep">Rank changes</h2>
<section class="panel"><div class="table-wrap" id="rank-log">${loading()}</div></section>`;
loadRankLog();
}
// The database's added/removed are shown as promotions/demotions too: gaining or losing a rank.
const RANK_CHANGE = {
added: ["Promoted", "green"],
promoted: ["Promoted", "green"],
demoted: ["Demoted", "amber"],
changed: ["Moved", ""],
removed: ["Demoted", "amber"],
};
async function loadRankLog() {
const box = $("#rank-log");
if (!box) return;
const want = state.num;
loadRankLog.want = want;
let list;
try { list = await api("/api/public/ranks" + qs({ page: state.num })); } catch (err) { box.innerHTML = errorBox(err); return; }
if (loadRankLog.want !== want || !$("#rank-log")) return;
if (!list.items.length) { box.innerHTML = '<div class="empty">No rank changes recorded yet.</div>'; return; }
box.innerHTML = `<table>
<thead><tr><th>Player</th><th>Change</th><th class="hide-sm">By</th><th>When</th></tr></thead>
<tbody>
${list.items.map((c) => {
const [label, tone] = RANK_CHANGE[c.kind] || [c.kind, ""];
const by = c.adminSteamid
? `<a href="${steamProfile(c.adminSteamid)}" target="_blank" rel="noopener">${esc(c.admin)}</a>`
: esc(c.admin);
return `<tr class="clickable" data-player="${esc(c.steamid)}">
<td><div class="who"><div><b>${esc(c.name || "Unknown")}</b><span class="sid">${esc(c.steamid)}</span></div></div></td>
<td><div class="rank-move"><span class="tag ${tone}">${label}</span>${rankTag(c.old)}<span class="faint" aria-label="to">→</span>${rankTag(c.new)}</div></td>
<td class="hide-sm">${by}${c.source === "game" ? ' <span class="faint">in game</span>' : ""}</td>
<td>${fmtDate(c.created)}</td>
</tr>`;
}).join("")}
</tbody>
</table>` + pager(list);
}
/* ---------- Player lookup (read-only) ---------- */
async function openPlayer(sid) {
$("#drawer-root").innerHTML = `<div class="scrim" data-close></div><aside class="drawer" role="dialog" aria-modal="true">${loading()}</aside>`;
let p;
try { p = await api(`/api/public/players/${encodeURIComponent(sid)}`); } catch (err) {
$(".drawer").innerHTML = errorBox(err);
return;
}
const history = [...p.bans, ...p.comms].sort((a, b) => new Date(b.created) - new Date(a.created));
const now = history.filter((h) => h.status === "active");
const label = (h) => (h.kind === "ban" ? "Ban" : COMM_LABEL[h.type]);
const name = p.name || "Unknown player";
$("#drawer-root").innerHTML = `
<div class="scrim" data-close></div>
<aside class="drawer" role="dialog" aria-modal="true" aria-label="${esc(name)}">
<div class="drawer-head">
<span class="avatar lg">${initial(name)}</span>
<div><h2>${esc(name)}</h2><span class="sid">${esc(p.steamid)} · <a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">Steam profile</a></span>${p.rank ? `<div>${rankTag(p.rank)}</div>` : ""}</div>
<button class="close" type="button" data-close aria-label="Close">×</button>
</div>
${now.length || p.online || p.verified ? `<section>
<div class="chips">${p.verified ? VERIFIED_TAG : ""}${p.online ? '<span class="tag">On the server</span>' : ""}${now.map((h) => `<span class="tag ${h.kind === "ban" ? "red" : "amber"}">${h.kind === "ban" ? "Banned" : COMM_HELP[h.type]}${h.duration ? `, ${fmtLeft(served(h).left)}` : ", permanently"}</span>`).join("")}</div>
</section>` : ""}
<section>
<h3>History</h3>
${history.length ? `<ul class="history">${history.map((h) => `<li>
<div class="top"><span><b>${label(h)}</b> <span class="muted">${fmtDate(h.created)}</span></span>${statusTag(h)}</div>
<div>${esc(h.reason)}</div>
${termCell(h)}
</li>`).join("")}</ul>` : '<p class="muted" style="margin:0">No bans, gags or mutes on record.</p>'}
</section>
</aside>`;
$(".drawer .close").focus();
}
/* ---------- Routing & events ---------- */
const RENDER = { server, bans, comms, staff };
function route() {
const [page, query] = location.hash.slice(1).split("?");
state.page = RENDER[page] ? page : "server";
// A link like #bans?q=<steamid> (from a staff notification) opens the list searching for that
// player, showing every status so lifted and expired ones are there too.
const q = new URLSearchParams(query || "").get("q");
if (q && (state.page === "bans" || state.page === "comms")) {
state.query = q.slice(0, 64);
state.num = 1;
state[state.page === "bans" ? "banFilter" : "commFilter"] = "all";
}
$("#nav-links").innerHTML = PAGES.map((p) => `<li><a href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}</a></li>`).join("");
RENDER[state.page]();
}
window.addEventListener("hashchange", () => { state.query = ""; state.num = 1; route(); window.scrollTo(0, 0); });
const search = debounce(() => loadResults(), 250);
document.addEventListener("input", (e) => {
if (e.target.id === "q") { state.query = e.target.value; state.num = 1; search(); }
});
document.addEventListener("click", (e) => {
const t = e.target;
const seg = t.closest("[data-seg] button");
if (seg) {
state[seg.parentElement.dataset.seg] = seg.dataset.value;
state.num = 1;
seg.parentElement.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === seg)));
loadResults();
return;
}
const pg = t.closest("[data-page]");
if (pg) { state.num = Number(pg.dataset.page); (state.page === "staff" ? loadRankLog : loadResults)(); return; }
if (t.closest("[data-close]")) { closeDrawer(); return; }
if (t.closest("a[href]")) return;
const row = t.closest("[data-player]");
if (row) openPlayer(row.dataset.player);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("#drawer-root").innerHTML) closeDrawer();
});
api("/api/me").then((me) => {
$("#site-name").innerHTML = siteName(me.site);
document.title = `${me.site} bans and staff`;
if (me.staff) $(".signin").innerHTML = "Staff panel";
}).catch(() => {});
// Keep the scoreboard current while it's on screen.
setInterval(() => { if (state.page === "server" && !document.hidden && !$("#drawer-root").innerHTML) server(); }, 15000);
route();