simpleadmin-web/internal/web/assets/public.js
Astra b26360e8f3 Console panel, collapsible panels, Add on the Members panel
- Server settings > Console sends a raw console command for staff with @css/rcon
  (POST /api/admin/rcon). It doesn't wait for the server; the output is only logged.
- Staff & ranks and Server settings panels fold up by clicking their heads. All but
  Ranks and Server start folded; a folded Ranks panel shows the selected rank.
- The Members panel has an Add button that opens Add staff with that rank picked.
- The public rank log shows a rank being given as Promoted and taken away as Demoted.
2026-10-03 12:09:43 +01:00

306 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 = location.hash.slice(1);
state.page = RENDER[page] ? page : "server";
$("#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();