simpleadmin-web 0.1.0: web panel for CS2-SimpleAdmin

This commit is contained in:
Astra 2026-09-25 20:08:14 +01:00
commit 4f38daf0e6
31 changed files with 6870 additions and 0 deletions

View file

@ -0,0 +1,55 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Staff panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="app">
<nav class="nav" id="nav" aria-label="Main">
<a class="brand" href="#overview">
<svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
<path d="M16 2 28 7v9c0 7-5 12-12 14C9 28 4 23 4 16V7z" fill="none" stroke="#fe113d" stroke-width="2.5"/>
<path d="M11 16h10M16 11v10" stroke="#e6e8ee" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<span class="brand-name site-name"></span>
</a>
<div class="server-pick" id="server-pick">
<b>Loading…</b>
<small>&nbsp;</small>
</div>
<ul id="nav-links"></ul>
<a class="public-link" href="/">View the public site</a>
<div class="me" id="me">
<span class="avatar">?</span>
<div><b>&nbsp;</b><small>&nbsp;</small></div>
<button class="btn sm ghost" type="button" id="signout">Sign out</button>
</div>
</nav>
<div>
<div class="mobile-bar">
<button class="btn sm ghost" type="button" id="menu-btn" aria-controls="nav" aria-expanded="false">Menu</button>
<span class="brand-name site-name" style="font-size:19px"></span>
</div>
<main id="view" tabindex="-1"></main>
</div>
</div>
<div id="drawer-root"></div>
<dialog id="modal"></dialog>
<div id="toast-root" aria-live="polite"></div>
<script src="/static/common.js"></script>
<script src="/static/admin.js"></script>
</body>
</html>

View file

@ -0,0 +1,835 @@
// Staff panel: hash-routed pages over the /api/admin endpoints. Buttons only appear for what the
// signed-in rank may do, but the server checks every action again.
const PAGES = [
{ id: "overview", label: "Overview" },
{ id: "players", label: "Players" },
{ id: "bans", label: "Bans" },
{ id: "comms", label: "Gags & mutes" },
{ id: "staff", label: "Staff & ranks" },
{ id: "settings", label: "Server settings" },
];
const state = {
page: "overview",
banFilter: "active",
commFilter: "active",
playerFilter: "online",
query: "",
num: 1,
group: null,
me: null,
counts: {},
};
const can = (flag) => !!state.me && (state.me.perms.includes("@css/root") || state.me.perms.includes(flag));
/* ---------- Overview ---------- */
const ACTIVITY_VERB = {
ban: "banned", warn: "warned", unban: "unbanned",
comm: { GAG: "gagged", MUTE: "muted", SILENCE: "silenced" },
unmute: { GAG: "lifted the gag on", MUTE: "lifted the mute on", SILENCE: "lifted the silence on" },
};
const ACTIVITY_COLOR = { ban: "var(--accent)", comm: "var(--amber)", warn: "var(--faint)", unban: "var(--green)", unmute: "var(--green)" };
function activityLine(a) {
const verb = typeof ACTIVITY_VERB[a.kind] === "object" ? ACTIVITY_VERB[a.kind][a.type] : ACTIVITY_VERB[a.kind];
const len = (a.kind === "ban" || a.kind === "comm") ? (a.duration === 0 ? " permanently" : ` for ${fmtDuration(a.duration)}`) : "";
return `<li style="--c:${ACTIVITY_COLOR[a.kind]}"><div><p><b>${esc(a.admin)}</b> ${verb} <b>${esc(a.name || a.steamid)}</b>${len}</p>
<small>${fmtAgo(a.at)}${a.reason ? `, ${esc(a.reason)}` : ""}</small></div></li>`;
}
function liveHeader(s) {
if (!s.online) {
return `<div class="live"><div><p class="live-host">The panel can't reach the game server, so live status and in-game actions are off. Bans and blocks still save to the database.</p><div class="live-map">Offline</div></div></div>`;
}
return `<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>T vs CT</small></div>
<div><b>${s.staffOnline}</b><small>Staff online</small></div>
</div>
</div>`;
}
function tagsFor(p) {
return (p.tags || []).map((t) => {
if (t.startsWith("WARN:")) {
const n = t.slice(5);
return `<span class="tag" title="Active warnings">${esc(n)} warn${n === "1" ? "" : "s"}</span>`;
}
return `<span class="tag amber" title="${COMM_HELP[t]}">${COMM_LABEL[t]}</span>`;
}).join(" ");
}
function onlineTable(players) {
const list = players.filter((p) => !p.bot).sort((a, b) => (a.team === b.team ? b.kills - a.kills : b.team - a.team));
if (!list.length) return '<div class="empty">Nobody is on the server.</div>';
const teamName = { 2: "T", 3: "CT" };
return `<table>
<thead><tr><th>Player</th><th class="hide-sm">Rank</th><th class="num">K / D</th><th class="num hide-sm">Ping</th><th><span class="sr">Actions</span></th></tr></thead>
<tbody>
${list.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td><div class="who"><span class="team ${p.team === 3 ? "ct" : p.team === 2 ? "t" : ""}">${teamName[p.team] || "SPEC"}</span><div><b>${esc(p.name)}</b> ${tagsFor(p)}</div></div></td>
<td class="hide-sm">${rankTag(p.rank)}</td>
<td class="num">${p.kills} / ${p.deaths}</td>
<td class="num hide-sm ${p.ping > 80 ? "" : "muted"}">${p.ping}</td>
<td><div class="row-actions">${p.canTarget ? `
${can("@css/chat") ? `<button class="btn sm ghost" type="button" data-act="comm" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}">Gag</button>` : ""}
${can("@css/kick") ? `<button class="btn sm ghost" type="button" data-act="kick" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}">Kick</button>` : ""}
${can("@css/ban") ? `<button class="btn sm danger" type="button" data-act="ban" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}">Ban</button>` : ""}` : ""}
</div></td>
</tr>`).join("")}
</tbody>
</table>`;
}
async function overview() {
const v = $("#view");
let d;
try { d = await api("/api/admin/overview"); } catch (err) { v.innerHTML = errorBox(err); return; }
if (state.page !== "overview") return;
state.counts = { bans: d.activeBans, comms: d.activeComms, staff: d.staffCount, players: d.server.online ? `${d.server.count}/${d.server.maxPlayers}` : "" };
renderNav();
setServerPick(d.server);
const s = d.server;
v.innerHTML = `
${liveHeader(s)}
<div class="grid-2">
<section class="panel">
<div class="panel-head">
<h2>On the server</h2>
<div style="display:flex;gap:8px;flex-wrap:wrap">
${can("@css/changemap") ? '<button class="btn sm" type="button" data-act="map">Change map</button>' : ""}
${can("@css/chat") ? '<button class="btn sm" type="button" data-act="say">Message all</button>' : ""}
</div>
</div>
<div class="table-wrap">${s.online ? onlineTable(s.players) : '<div class="empty">Offline.</div>'}</div>
</section>
<div class="stack">
<section class="panel">
<div class="panel-head"><h2>Recent actions</h2><a class="muted" href="#bans" style="font-size:14px">All bans</a></div>
${d.activity.length ? `<ul class="feed">${d.activity.map(activityLine).join("")}</ul>` : '<div class="empty">Nothing yet.</div>'}
</section>
<section class="panel">
<div class="panel-head"><h2>Ending soon</h2><p>${d.activeBans} bans, ${d.activeComms} blocks active</p></div>
${d.expiring.length ? `<ul class="expiring">${d.expiring.map((p) => `<li><span><a href="#" data-player="${esc(p.steamid)}"><b>${esc(p.name || p.steamid)}</b></a> <span class="muted">${p.kind === "ban" ? "ban" : COMM_LABEL[p.type].toLowerCase()}</span></span><span class="muted">${fmtLeft(served(p).left)}</span></li>`).join("")}</ul>` : '<div class="empty">No timed bans or blocks.</div>'}
</section>
</div>
</div>`;
}
function setServerPick(s) {
const el = $("#server-pick");
el.innerHTML = s.online
? `<b><span class="dot"></span>${esc(s.hostname)}</b><small>${esc(s.map)}, ${s.count}/${s.maxPlayers} players</small>`
: `<b>Server offline</b><small>Live status unavailable</small>`;
}
/* ---------- Players ---------- */
function players() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Players</h1><p class="sub">Everyone SimpleAdmin has seen join, with their rank and record. Search by name, SteamID64${can("@css/showip") ? " or IP address" : ""}.</p></div>
</div>
<section class="panel">
<div class="toolbar">
${searchBox("Search players", state.query)}
${segmented("playerFilter", state.playerFilter, [["online", "Online"], ["ranked", "Ranked"], ["all", "All"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
loadResults();
}
function playersTable(list) {
if (!list.items.length) {
if (state.query) return `<div class="empty">No players match "${esc(state.query)}". Try a SteamID64 or part of a name.</div>`;
return `<div class="empty">${state.playerFilter === "online" ? "Nobody is on the server." : "No players yet."}</div>`;
}
return `<table>
<thead><tr><th>Player</th><th>Rank</th><th class="hide-sm">Record</th><th class="hide-sm">Last seen</th></tr></thead>
<tbody>
${list.items.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td><div class="who"><span class="avatar">${initial(p.name)}</span><div><b>${esc(p.name || p.steamid)}</b><span class="sid">${esc(p.steamid)}</span></div></div></td>
<td>${rankTag(p.rank)}</td>
<td class="hide-sm">
${p.banned ? '<span class="tag red">Banned</span>' : ""}
${p.bans ? `<span class="tag">${p.bans} ban${p.bans > 1 ? "s" : ""}</span>` : ""}
${p.comms ? `<span class="tag">${p.comms} block${p.comms > 1 ? "s" : ""}</span>` : ""}
${p.warns ? `<span class="tag">${p.warns} warn${p.warns > 1 ? "s" : ""}</span>` : ""}
${!p.bans && !p.comms && !p.warns ? '<span class="faint">Clean</span>' : ""}
</td>
<td class="hide-sm">${p.online ? '<span class="tag green">Online</span>' : p.lastSeen ? `<span class="muted">${fmtAgo(p.lastSeen)}</span>` : '<span class="faint">Never joined</span>'}</td>
</tr>`).join("")}
</tbody>
</table>${pager(list)}`;
}
/* ---------- Bans and comms ---------- */
function punishTable(list, kind) {
if (!list.items.length) {
return `<div class="empty">${state.query ? `Nothing matches "${esc(state.query)}".` : kind === "ban" ? "No bans in this view." : "No gags or mutes in this view."}</div>`;
}
const canLift = kind === "ban" ? can("@css/unban") : can("@css/chat");
const canAdd = kind === "ban" ? can("@css/ban") : can("@css/chat");
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>
<th><span class="sr">Actions</span></th>
</tr></thead>
<tbody>
${list.items.map((p) => `<tr>
<td><div class="who"><div><b>${p.steamid ? `<a href="#" data-player="${esc(p.steamid)}">${esc(p.name || p.steamid)}</a>` : esc(p.name || "Unknown")}</b><span class="sid">${esc(p.steamid)}${p.ip ? `<br>${esc(p.ip)}` : ""}</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)}${p.liftedBy || p.liftReason ? `<br><small class="muted">Lifted${p.liftedBy ? ` by ${esc(p.liftedBy)}` : ""}${p.liftReason ? `: ${esc(p.liftReason)}` : ""}</small>` : ""}</td>
<td>${termCell(p)}</td>
<td class="hide-sm"><span>${fmtDate(p.created)}</span><br><small class="muted">by ${esc(p.admin || "Console")}</small></td>
<td>${statusTag(p)}</td>
<td><div class="row-actions">
${p.status === "active" && canLift && p.steamid ? `<button class="btn sm danger" type="button" data-act="lift" data-kind="${kind}" data-id="${p.id}" data-name="${esc(p.name)}" data-type="${esc(p.type || "")}">${kind === "ban" ? "Unban" : "Lift"}</button>` : ""}
${p.status !== "active" && canAdd && p.steamid ? `<button class="btn sm ghost" type="button" data-act="${kind === "ban" ? "ban" : "comm"}" data-sid="${esc(p.steamid)}" data-type="${esc(p.type || "")}" data-reason="${esc(p.reason)}">Reapply</button>` : ""}
</div></td>
</tr>`).join("")}
</tbody>
</table>${pager(list)}`;
}
function bans() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Bans</h1><p class="sub">The bar under each length shows how much of the ban has been served. Striped bars never expire.</p></div>
${can("@css/ban") ? `<button class="btn primary" type="button" data-act="ban">${ICON.plus}Add ban</button>` : ""}
</div>
<section class="panel">
<div class="toolbar">
${searchBox(`Search by name, SteamID, ${can("@css/showip") ? "IP, " : ""}reason or admin`, 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.</p></div>
${can("@css/chat") ? `<button class="btn primary" type="button" data-act="comm">${ICON.plus}Add gag or mute</button>` : ""}
</div>
<section class="panel">
<div class="toolbar">
${searchBox("Search by name, SteamID, reason or admin", 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();
}
async function loadResults() {
const box = $("#results");
if (!box) return;
let path, status, render;
if (state.page === "players") {
path = "/api/admin/players"; render = playersTable;
} else if (state.page === "bans") {
path = "/api/admin/bans"; status = state.banFilter; render = (l) => punishTable(l, "ban");
} else {
path = "/api/admin/comms"; status = state.commFilter; render = (l) => punishTable(l, "comm");
}
const params = { status, q: state.query, page: state.num, filter: state.page === "players" ? state.playerFilter : undefined };
const want = JSON.stringify([state.page, params]);
loadResults.want = want;
try {
const list = await api(path + qs(params));
if (loadResults.want !== want) return;
box.innerHTML = render(list);
} catch (err) {
box.innerHTML = errorBox(err);
}
}
/* ---------- Staff & ranks ---------- */
async function staff() {
const v = $("#view");
let d;
try { d = await api("/api/admin/staff"); } catch (err) { v.innerHTML = errorBox(err); return; }
if (state.page !== "staff") return;
staff.data = d;
const manage = d.canManage;
if (!d.groups.some((g) => g.id === state.group)) state.group = d.groups[0]?.id ?? null;
const g = d.groups.find((x) => x.id === state.group);
const ladderItem = (x) => `<li><button type="button" data-group="${x.id}" aria-current="${x.id === state.group}" style="--c:${esc(x.color)}">
<i></i><span><b>${esc(x.label)}</b><small>${x.members.length} ${x.members.length === 1 ? "member" : "members"}</small></span><span class="imm">${x.immunity}</span>
</button></li>`;
const staffGroups = d.groups.filter((x) => !x.supporter);
const supporters = d.groups.filter((x) => x.supporter);
const memberRow = (p, groupName) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td><div class="who"><span class="avatar">${initial(p.name)}</span><div><b>${esc(p.name)}</b><span class="sid">${esc(p.steamid)}</span></div></div></td>
<td class="hide-sm">${p.online ? '<span class="tag green">Online</span>' : p.lastSeen ? `<span class="muted">Seen ${fmtAgo(p.lastSeen)}</span>` : '<span class="faint">Never joined</span>'}</td>
<td class="muted hide-sm">${p.ends ? `Until ${fmtDate(p.ends)}` : "No expiry"}</td>
<td><div class="row-actions">${manage && p.steamid !== d.me ? `<button class="btn sm ghost" type="button" data-act="rank" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}" data-group="${esc(groupName)}">Change rank</button>` : ""}</div></td>
</tr>`;
const allFlags = [...d.flags];
if (g) for (const f of g.flags) if (!allFlags.some((x) => x.flag === f)) allFlags.push({ flag: f, label: f, help: "Custom permission" });
const root = g && g.flags.includes("@css/root");
v.innerHTML = `
<div class="page-head">
<div><h1>Staff & ranks</h1><p class="sub">Ranks are listed by immunity. Staff can only act on players whose immunity isn't higher than their own.${manage ? "" : " Only Root can change ranks and staff."}</p></div>
${manage ? `<div style="display:flex;gap:8px">
<button class="btn" type="button" data-act="new-group">${ICON.plus}New rank</button>
<button class="btn primary" type="button" data-act="add-staff">${ICON.plus}Add staff</button>
</div>` : ""}
</div>
${d.groups.length ? `<div class="ranks-layout">
<section class="panel">
<div class="panel-head"><h2>Ranks</h2><p>Immunity</p></div>
<ul class="ladder">
${staffGroups.map(ladderItem).join("")}
${supporters.length ? `<li class="sep">Supporter ranks</li>${supporters.map(ladderItem).join("")}` : ""}
</ul>
</section>
<div class="stack">
<section class="panel">
<div class="panel-head">
<h2><span class="rank" style="--c:${esc(g.color)};font:inherit"><i></i>${esc(g.label)}</span></h2>
<span class="sid">${esc(g.name)}</span>
</div>
<form class="panel-body" id="group-form" style="display:grid;gap:18px" data-id="${g.id}">
<div class="form-row">
<label class="field"><span>SimpleAdmin name</span><input class="input" name="name" value="${esc(g.name)}" ${manage ? "" : "readonly"} required><small>The display name and colour come from the panel's site config.</small></label>
<label class="field"><span>Immunity</span><input class="input" name="immunity" type="number" min="0" max="9999" value="${g.immunity}" ${manage ? "" : "readonly"} required><small>0 to 9999</small></label>
</div>
<div>
<h3 style="margin-bottom:10px">Permissions</h3>
<div class="flag-grid">
${allFlags.map((f) => {
const on = g.flags.includes(f.flag);
const inherited = root && f.flag !== "@css/root";
return `<label class="flag"><input type="checkbox" name="flag" value="${esc(f.flag)}" ${on || inherited ? "checked" : ""} ${inherited || !manage ? "disabled" : ""} ${on && inherited ? 'data-kept="1"' : ""}>
<span><b>${esc(f.label)}</b><small>${inherited ? "Included by Root" : esc(f.help)}</small><br><small class="faint">${esc(f.flag)}</small></span></label>`;
}).join("")}
</div>
</div>
${manage ? `<div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap">
<button class="btn danger" type="button" data-act="delete-group" data-id="${g.id}" data-name="${esc(g.label)}">Delete rank</button>
<button class="btn primary" type="submit">Save rank</button>
</div>` : ""}
</form>
</section>
<section class="panel">
<div class="panel-head"><h2>Members</h2><p>${g.members.length} ${g.members.length === 1 ? "person" : "people"}</p></div>
<div class="table-wrap">
${g.members.length ? `<table><tbody>${g.members.map((p) => memberRow(p, g.name)).join("")}</tbody></table>`
: `<div class="empty">Nobody has this rank yet.${manage ? " Use Add staff to give it to someone." : ""}</div>`}
</div>
</section>
${d.unranked.length ? `<section class="panel">
<div class="panel-head"><h2>Admins without a rank</h2><p>Permissions set directly in SimpleAdmin</p></div>
<div class="table-wrap"><table><tbody>${d.unranked.map((p) => memberRow(p, "")).join("")}</tbody></table></div>
</section>` : ""}
</div>
</div>` : `<div class="empty">SimpleAdmin has no ranks for this server yet.${manage ? " Use New rank to create one." : ""}</div>`}`;
}
/* ---------- Settings ---------- */
async function settings() {
const v = $("#view");
let d;
try { d = await api("/api/admin/settings"); } catch (err) { v.innerHTML = errorBox(err); return; }
if (state.page !== "settings") return;
const sa = d.simpleadmin;
const cvarControl = (c) => {
if (c.type === "bool") return `<button class="switch" type="button" role="switch" aria-checked="${c.value}" aria-label="${esc(c.label)}" data-cvar="${esc(c.key)}"></button>`;
if (c.type === "int") return `<input class="input" type="number" value="${esc(c.value)}" data-cvar="${esc(c.key)}">`;
return `<input class="input" value="${esc(c.value)}" data-cvar="${esc(c.key)}" ${c.key === "sv_password" ? 'placeholder="No password"' : ""}>`;
};
const saControl = (s) => {
const val = sa.values[s.key];
const dis = d.canRoot ? "" : "disabled";
if (s.type === "bool") return `<button class="switch" type="button" role="switch" aria-checked="${val}" aria-label="${esc(s.label)}" data-sa="${esc(s.key)}" ${dis}></button>`;
if (s.type === "select") return `<select class="input" data-sa="${esc(s.key)}" ${dis}>${s.options.map((o) => `<option value="${o.value}" ${o.value === val ? "selected" : ""}>${esc(o.label)}</option>`).join("")}</select>`;
return `<input class="input" type="number" min="${s.min || 0}" value="${val}" data-sa="${esc(s.key)}" ${dis}>`;
};
const row = (label, help, key, control) => `<div class="setting">
<div><b>${esc(label)}</b>${help ? `<p>${esc(help)}</p>` : ""}${key ? `<code>${esc(key)}</code>` : ""}</div>
<div class="control">${control}</div>
</div>`;
v.innerHTML = `
<div class="page-head">
<div><h1>Server settings</h1><p class="sub">Server changes go live over RCON. SimpleAdmin's settings are saved to its config file.</p></div>
</div>
<div class="settings">
<section class="panel" id="cvars">
<div class="panel-head"><h2>Server</h2><p>Live over RCON. Resets when the server restarts.</p></div>
${!d.canCvar ? '<div class="empty">Your rank can\'t change server settings.</div>'
: !d.server.online ? '<div class="empty">The game server isn\'t answering, so these can\'t be read or changed right now.</div>'
: (d.cvars || []).map((c) => row(c.label, c.help, c.key, cvarControl(c))).join("")}
</section>
<section class="panel" id="sa">
<div class="panel-head"><h2>Punishments</h2><p>${sa.available ? `${sa.path ? `Saved to ${esc(sa.path)}. ` : ""}Applies after the next server restart.` : "Read-only"}</p></div>
${!sa.available ? '<div class="empty">The panel wasn\'t given CS2-SimpleAdmin.json (SAW_SA_CONFIG), so these can\'t be shown or changed here.</div>'
: sa.error ? `<div class="empty">${esc(sa.error)}</div>`
: sa.settings.map((s) => row(s.label, s.help, s.key, saControl(s))).join("")}
</section>
<section class="panel">
<div class="panel-head"><h2>Connections</h2></div>
${row("Database", "SimpleAdmin's MySQL database.", "", d.database.connected ? '<span class="tag green">Connected</span>' : '<span class="tag red">Not connected</span>')}
${row("Game server", d.server.online ? "Live status through the WebPanelBridge plugin." : d.server.error || "", "", d.server.online ? '<span class="tag green">Connected</span>' : '<span class="tag red">Not answering</span>')}
</section>
<section class="panel">
<div class="panel-head"><h2>Maintenance</h2><p>Each of these asks first.</p></div>
${can("@css/root") ? row("Reload admins", "Re-read ranks and staff from the database. Runs css_reloadadmins.", "", '<button class="btn" type="button" data-act="reload-admins">Reload admins</button>') : ""}
${can("@css/generic") ? row("Restart the match", "Restarts the game on the current map. Runs css_rr.", "", '<button class="btn" type="button" data-act="restart">Restart match</button>') : ""}
${can("@css/changemap") ? row("Change map", "Switch to another map, or a Workshop map with ws:<id>.", "", '<button class="btn" type="button" data-act="map">Change map</button>') : ""}
</section>
</div>
<div class="savebar" id="savebar" hidden>
<span>You have unsaved changes.</span>
<div style="display:flex;gap:8px"><button class="btn ghost" type="button" data-act="discard">Discard</button><button class="btn primary" type="button" data-act="save-settings">Save changes</button></div>
</div>`;
}
function markDirty() { const b = $("#savebar"); if (b) b.hidden = false; }
async function saveSettings() {
const cvars = {};
const sa = {};
document.querySelectorAll("[data-cvar].dirty").forEach((el) => {
const key = el.dataset.cvar;
if (el.classList.contains("switch")) cvars[key] = el.getAttribute("aria-checked") === "true";
else cvars[key] = el.type === "number" ? Number(el.value) : el.value;
});
document.querySelectorAll("[data-sa].dirty").forEach((el) => {
const key = el.dataset.sa;
if (el.classList.contains("switch")) sa[key] = el.getAttribute("aria-checked") === "true";
else sa[key] = Number(el.value);
});
const msgs = [];
try {
if (Object.keys(cvars).length) msgs.push((await api("/api/admin/settings/cvars", { method: "POST", body: cvars })).message);
if (Object.keys(sa).length) msgs.push((await api("/api/admin/settings/simpleadmin", { method: "POST", body: sa })).message);
} catch (err) {
toast(err.message);
return;
}
toast(msgs.join(" ") || "Nothing to save.");
settings();
}
/* ---------- Player drawer ---------- */
async function openPlayer(sid) {
$("#drawer-root").innerHTML = `<div class="scrim" data-close></div><aside class="drawer" role="dialog" aria-modal="true">${loading()}</aside>`;
let d;
try { d = await api(`/api/admin/players/${encodeURIComponent(sid)}`); } catch (err) {
$(".drawer").innerHTML = errorBox(err);
return;
}
const p = d.player;
const name = p.name || p.steamid;
const history = [
...d.bans.map((b) => ({ ...b, label: "Ban" })),
...d.comms.map((c) => ({ ...c, label: COMM_LABEL[c.type] })),
...d.warns.map((w) => ({ ...w, label: "Warning" })),
].sort((a, b) => new Date(b.created) - new Date(a.created));
const t = d.canTarget;
const btn = (act, label, perm, extra = "", cls = "") => can(perm)
? `<button class="btn ${cls}" type="button" data-act="${act}" data-sid="${esc(p.steamid)}" data-name="${esc(name)}" ${extra} ${t ? "" : "disabled"}>${label}</button>` : "";
const manage = can("@css/root") && p.steamid !== state.me.steamid;
$("#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><div>${rankTag(d.rank)} ${d.online ? '<span class="tag green" style="margin-left:6px">Online</span>' : ""}</div></div>
<button class="close" type="button" data-close aria-label="Close">×</button>
</div>
<section>
${t ? "" : `<p class="muted" style="margin:0 0 12px">${p.steamid === state.me.steamid ? "This is you." : "This player's immunity is higher than yours, so you can't act on them."}</p>`}
<div class="action-grid">
${btn("ban", "Ban", "@css/ban", "", "danger")}
${btn("comm", "Gag", "@css/chat", 'data-type="GAG"')}
${btn("comm", "Mute", "@css/chat", 'data-type="MUTE"')}
${btn("comm", "Silence", "@css/chat", 'data-type="SILENCE"')}
${d.online ? btn("warn", "Warn", "@css/kick") : ""}
${d.online ? btn("kick", "Kick", "@css/kick") : ""}
</div>
</section>
<section>
<h3>Details</h3>
<dl class="kv">
<dt>SteamID64</dt><dd>${esc(p.steamid)}</dd>
<dt>Profile</dt><dd><a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">Steam profile</a></dd>
${p.ips ? `<dt>Known IPs</dt><dd>${p.ips.map(esc).join(", ") || '<span class="faint">None</span>'}</dd>` : ""}
<dt>Known names</dt><dd>${(p.names || []).map(esc).join(", ") || '<span class="faint">None</span>'}</dd>
<dt>Last seen</dt><dd>${d.online ? "On the server now" : p.lastSeen ? fmtAgo(p.lastSeen) : "Never joined"}</dd>
${d.immunity ? `<dt>Immunity</dt><dd>${d.immunity}</dd>` : ""}
${d.adminEnds ? `<dt>Rank expires</dt><dd>${fmtDate(d.adminEnds)}</dd>` : ""}
</dl>
</section>
${manage ? `<section>
<h3>Rank</h3>
<div style="display:flex;gap:8px">
<button class="btn" type="button" data-act="rank" data-sid="${esc(p.steamid)}" data-name="${esc(name)}" data-group="${esc(d.rank?.name || "")}" ${t ? "" : "disabled"}>${d.rank ? "Change rank" : "Give a rank"}</button>
</div>
</section>` : ""}
<section>
<h3>History</h3>
${history.length ? `<ul class="history">${history.map((h) => `<li>
<div class="top"><span><b>${h.label}</b> <span class="muted">by ${esc(h.admin || "Console")}, ${fmtAgo(h.created)}</span></span>${statusTag(h)}</div>
<div>${esc(h.reason)}</div>
${h.kind === "warn" ? "" : termCell(h)}
</li>`).join("")}</ul>` : '<p class="muted" style="margin:0">No bans, gags, mutes or warnings on record.</p>'}
</section>
</aside>`;
$(".drawer .close").focus();
}
/* ---------- Dialogs ---------- */
const DURATIONS = [[30, "30 min"], [60, "1 hour"], [1440, "1 day"], [10080, "1 week"], [43200, "30 days"], [0, "Permanent"]];
const REASONS = {
ban: ["Cheating", "Ban evasion", "Toxicity", "Griefing", "Exploiting"],
comm: ["Spam", "Toxicity", "Mic spam", "Soundboard", "Advertising"],
warn: ["Toxicity", "Spam", "Griefing", "Ignoring staff"],
};
function punishDialog(kind, { sid = "", name = "", type = "GAG", reason = "" } = {}) {
const title = { ban: "Ban player", comm: "Gag or mute player", warn: "Warn player" }[kind];
const submit = { ban: "Ban player", comm: "Apply block", warn: "Warn player" }[kind];
const perm = kind === "ban" ? "@css/permban" : "@css/permmute";
openModal(`
<form method="dialog" data-submit="penalty" data-kind="${kind}">
<div class="modal-head"><h2>${title}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body">
<label class="field"><span>Player</span>
<input class="input" name="steamid" required pattern="7656119[0-9]{10}" value="${esc(sid)}" placeholder="SteamID64, like 76561198012345678" ${sid ? "readonly" : ""}>
<small>${name ? esc(name) : kind === "warn" ? "Warnings only work on players who are on the server." : "Works for players who are offline too; it applies when they next join."}</small>
</label>
${kind === "comm" ? `<div class="field"><span>Block</span>${segmented("commType", type || "GAG", [["GAG", "Text (gag)"], ["MUTE", "Voice (mute)"], ["SILENCE", "Both (silence)"]])}</div>` : ""}
<div class="field"><span>${kind === "warn" ? "Warning lasts" : "Length"}</span>
<div class="chips" data-chips="dur">${DURATIONS.map(([v, l], i) => `<button type="button" class="chip-btn" data-value="${v}" aria-pressed="${i === (kind === "ban" ? 2 : kind === "warn" ? 4 : 1)}">${l}</button>`).join("")}</div>
${kind === "warn" ? "" : `<small>${can(perm) ? "Your rank can use any length." : "Your rank's limit is set in SimpleAdmin's settings; permanent isn't allowed."}</small>`}
</div>
<div class="field"><span>Reason</span>
<div class="chips" data-chips="reason">${REASONS[kind].map((r) => `<button type="button" class="chip-btn" data-value="${esc(r)}" aria-pressed="false">${esc(r)}</button>`).join("")}</div>
<textarea class="input" name="reason" id="reason" required maxlength="200" placeholder="Shown to the player and in the log">${esc(reason)}</textarea>
</div>
</div>
<div class="modal-foot">
<button class="btn ghost" type="button" data-modal-close>Cancel</button>
<button class="btn primary" type="submit">${submit}</button>
</div>
</form>`);
}
async function rankDialog(sid, name, current) {
if (!staff.data) {
try { staff.data = await api("/api/admin/staff"); } catch (err) { toast(err.message); return; }
}
const groups = staff.data.groups;
const opts = (groups || []).map((g) => `<option value="${esc(g.name)}" ${g.name === current ? "selected" : ""}>${esc(g.label)} (${g.immunity})</option>`).join("");
openModal(`
<form method="dialog" data-submit="rank" data-sid="${esc(sid)}" data-had="${esc(current)}">
<div class="modal-head"><h2>${current ? "Change rank" : "Add staff"}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body">
<label class="field"><span>Player</span>
<input class="input" name="steamid" required pattern="7656119[0-9]{10}" value="${esc(sid)}" placeholder="SteamID64, like 76561198012345678" ${sid ? "readonly" : ""}>
${name ? `<small>${esc(name)}</small>` : ""}
</label>
<label class="field"><span>Rank</span><select class="input" name="group">${current ? '<option value="">No rank (remove from staff)</option>' : ""}${opts}</select></label>
<label class="field"><span>Expires</span><select class="input" name="days"><option value="0">Never</option><option value="7">In 7 days</option><option value="30">In 30 days</option><option value="90">In 90 days</option></select><small>Trial ranks usually expire.</small></label>
</div>
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">Save</button></div>
</form>`);
}
function newGroupDialog() {
openModal(`
<form method="dialog" data-submit="new-group">
<div class="modal-head"><h2>New rank</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body">
<label class="field"><span>SimpleAdmin name</span><input class="input" name="name" required pattern="#[A-Za-z0-9_./\\-]{1,63}" placeholder="#rank/mod"><small>Starts with #. Add a display name and colour for it in the site config.</small></label>
<label class="field"><span>Immunity</span><input class="input" name="immunity" type="number" min="0" max="${state.me.immunity}" value="0" required><small>Up to your own immunity, ${state.me.immunity}.</small></label>
<p class="muted" style="margin:0">It starts with Generic admin only. Pick its other permissions after creating it.</p>
</div>
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">Create rank</button></div>
</form>`);
}
function textDialog(kind) {
const cfg = {
map: { title: "Change map", label: "Map", placeholder: "de_mirage, or ws:<workshop id>", submit: "Change map", pattern: "(ws:)?[A-Za-z0-9_\\-]{1,64}" },
say: { title: "Message everyone", label: "Message", placeholder: "Shown in chat to everyone on the server", submit: "Send", pattern: ".{1,200}" },
kick: { title: "Kick player", label: "Reason", placeholder: "Shown to the player", submit: "Kick", pattern: ".{0,200}" },
}[kind];
return cfg;
}
function inputDialog(kind, data = {}) {
const c = textDialog(kind);
openModal(`
<form method="dialog" data-submit="${kind}" data-sid="${esc(data.sid || "")}">
<div class="modal-head"><h2>${c.title}${data.name ? `: ${esc(data.name)}` : ""}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body"><label class="field"><span>${c.label}</span><input class="input" name="value" pattern="${c.pattern}" placeholder="${esc(c.placeholder)}" ${kind === "kick" ? "" : "required"} maxlength="200"></label></div>
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">${c.submit}</button></div>
</form>`);
}
function confirmDialog(title, body, label, run) {
confirmDialog.run = run;
openModal(`
<form method="dialog" data-submit="confirm">
<div class="modal-head"><h2>${title}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body"><p style="margin:0">${body}</p>${confirmDialog.reason ? '<label class="field"><span>Reason</span><input class="input" name="reason" maxlength="200" placeholder="Optional, kept in the log"></label>' : ""}</div>
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">${label}</button></div>
</form>`);
}
function openModal(html) {
const m = $("#modal");
m.innerHTML = html;
m.showModal();
}
// act runs an API call, shows its message, and refreshes the page and any open drawer.
async function act(path, method, body) {
try {
const res = await api(path, { method, body });
toast(res.message || "Done.");
} catch (err) {
toast(err.message);
return false;
}
const drawerSid = $(".drawer [data-sid]")?.dataset.sid;
route();
if (drawerSid) openPlayer(drawerSid);
return true;
}
/* ---------- Routing & events ---------- */
const RENDER = { overview, players, bans, comms, staff, settings };
function renderNav() {
const c = state.counts;
const count = { players: c.players, bans: c.bans, comms: c.comms, staff: c.staff };
$("#nav-links").innerHTML = PAGES.map((p) => `<li><a class="link" href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}${count[p.id] !== undefined && count[p.id] !== "" ? `<span class="count">${esc(count[p.id])}</span>` : ""}</a></li>`).join("");
}
function route() {
const page = location.hash.slice(1);
state.page = RENDER[page] ? page : "overview";
renderNav();
$("#nav").classList.remove("open");
$("#menu-btn").setAttribute("aria-expanded", "false");
if (!$("#view").innerHTML) $("#view").innerHTML = loading();
RENDER[state.page]();
}
window.addEventListener("hashchange", () => { state.query = ""; state.num = 1; $("#view").innerHTML = loading(); route(); window.scrollTo(0, 0); });
const search = debounce(() => loadResults(), 250);
document.addEventListener("input", (e) => {
const t = e.target;
if (t.id === "q") { state.query = t.value; state.num = 1; search(); return; }
if (t.matches("[data-cvar], [data-sa]")) { t.classList.add("dirty"); markDirty(); }
});
document.addEventListener("change", (e) => {
if (e.target.matches("select[data-sa]")) { e.target.classList.add("dirty"); markDirty(); }
});
document.addEventListener("click", (e) => {
const t = e.target;
if (t.closest("#menu-btn")) {
const open = $("#nav").classList.toggle("open");
$("#menu-btn").setAttribute("aria-expanded", String(open));
return;
}
if (t.closest("#signout")) {
api("/auth/logout", { method: "POST" }).finally(() => { location.href = "/"; });
return;
}
const seg = t.closest("[data-seg] button");
if (seg) {
const group = seg.parentElement;
group.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === seg)));
const key = group.dataset.seg;
if (key in state) { state[key] = seg.dataset.value; state.num = 1; loadResults(); }
return;
}
const pg = t.closest("[data-page]");
if (pg && !pg.disabled) { state.num = Number(pg.dataset.page); loadResults(); return; }
const chip = t.closest("[data-chips] .chip-btn");
if (chip) {
const box = chip.parentElement;
if (box.dataset.chips === "reason") $("#reason").value = chip.dataset.value;
box.querySelectorAll(".chip-btn").forEach((b) => b.setAttribute("aria-pressed", String(b === chip)));
return;
}
const sw = t.closest(".switch");
if (sw && !sw.disabled) {
sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true"));
sw.classList.add("dirty");
markDirty();
return;
}
const grp = t.closest("[data-group]:not([data-act])");
if (grp) { state.group = Number(grp.dataset.group); staff(); return; }
if (t.closest("[data-close]")) { closeDrawer(); return; }
if (t.closest("[data-modal-close]")) { $("#modal").close(); return; }
const a = t.closest("[data-act]");
if (a) {
e.preventDefault();
e.stopPropagation();
if (a.disabled) return;
const d = a.dataset;
switch (d.act) {
case "ban": return punishDialog("ban", { sid: d.sid, name: d.name, reason: d.reason });
case "comm": return punishDialog("comm", { sid: d.sid, name: d.name, type: d.type, reason: d.reason });
case "warn": return punishDialog("warn", { sid: d.sid, name: d.name });
case "kick": return inputDialog("kick", { sid: d.sid, name: d.name });
case "map": return inputDialog("map");
case "say": return inputDialog("say");
case "lift": {
const what = d.kind === "ban" ? "Unban" : `Lift ${COMM_LABEL[d.type].toLowerCase()}`;
confirmDialog.reason = true;
return confirmDialog(what, d.kind === "ban"
? `Unban <b>${esc(d.name || "this player")}</b>? They can join again straight away. This lifts every active ban on their SteamID.`
: `Lift the ${COMM_LABEL[d.type].toLowerCase()} on <b>${esc(d.name || "this player")}</b>?`,
d.kind === "ban" ? "Unban" : "Lift", (form) => act("/api/admin/penalties/lift", "POST", { kind: d.kind, id: Number(d.id), reason: form.reason?.value || "" }));
}
case "rank": return rankDialog(d.sid, d.name, d.group);
case "add-staff": return rankDialog("", "", "");
case "new-group": return newGroupDialog();
case "delete-group":
confirmDialog.reason = false;
return confirmDialog("Delete rank", `Delete <b>${esc(d.name)}</b>? Its members become regular players.`, "Delete rank",
() => act(`/api/admin/groups/${d.id}`, "DELETE"));
case "reload-admins":
confirmDialog.reason = false;
return confirmDialog("Reload admins", "Re-read all ranks and staff from the database?", "Reload admins", () => act("/api/admin/reload-admins", "POST", {}));
case "restart":
confirmDialog.reason = false;
return confirmDialog("Restart the match", "Restart the game on the current map? The current match ends.", "Restart match", () => act("/api/admin/restart", "POST", {}));
case "save-settings": return saveSettings();
case "discard": return settings();
}
return;
}
const row = t.closest("[data-player]");
if (row) { e.preventDefault(); openPlayer(row.dataset.player); }
});
document.addEventListener("submit", async (e) => {
const form = e.target;
e.preventDefault();
const kind = form.dataset.submit;
const f = form.elements;
if (form.id === "group-form") {
const flags = [...form.querySelectorAll('input[name="flag"]')].filter((i) => i.checked && (!i.disabled || i.dataset.kept)).map((i) => i.value);
await act(`/api/admin/groups/${form.dataset.id}`, "PUT", { name: f.name.value.trim(), immunity: Number(f.immunity.value), flags });
return;
}
$("#modal").close();
switch (kind) {
case "penalty": {
const dur = form.querySelector('[data-chips="dur"] [aria-pressed="true"]');
const type = form.querySelector('[data-seg="commType"] [aria-pressed="true"]')?.dataset.value;
await act("/api/admin/penalties", "POST", {
kind: form.dataset.kind, type: type || "", steamid: f.steamid.value.trim(),
duration: Number(dur?.dataset.value ?? 60), reason: f.reason.value,
});
break;
}
case "rank": {
const sid = f.steamid.value.trim();
const group = f.group.value;
if (form.dataset.had) await act(`/api/admin/staff/${encodeURIComponent(sid)}`, "PUT", { group, days: Number(f.days.value) });
else await act("/api/admin/staff", "POST", { steamid: sid, group, days: Number(f.days.value) });
break;
}
case "new-group":
if (await act("/api/admin/groups", "POST", { name: f.name.value.trim(), immunity: Number(f.immunity.value), flags: ["@css/generic"] })) {
state.group = null;
}
break;
case "map": await act("/api/admin/map", "POST", { map: f.value.value.trim() }); break;
case "say": await act("/api/admin/say", "POST", { message: f.value.value }); break;
case "kick": await act("/api/admin/kick", "POST", { steamid: form.dataset.sid, reason: f.value.value }); break;
case "confirm": await confirmDialog.run?.(f); break;
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("#drawer-root").innerHTML && !$("#modal").open) closeDrawer();
});
(async () => {
try {
state.me = await api("/api/me");
} catch (err) {
$("#view").innerHTML = errorBox(err);
return;
}
if (!state.me.staff) { location.href = "/admin"; return; }
const name = state.me.site;
const i = name.indexOf(".");
document.querySelectorAll(".site-name").forEach((el) => {
el.innerHTML = i > 0 ? `${esc(name.slice(0, i))}<span>.</span>${esc(name.slice(i + 1))}` : esc(name);
});
$("#me").innerHTML = `<span class="avatar">${initial(state.me.name)}</span>
<div><b>${esc(state.me.name)}</b><small>${esc(state.me.rank?.label || "Staff")}</small></div>
<button class="btn sm ghost" type="button" id="signout">Sign out</button>`;
// Fill the sidebar counts once, even when starting on another page.
api("/api/admin/overview").then((d) => {
state.counts = { bans: d.activeBans, comms: d.activeComms, staff: d.staffCount, players: d.server.online ? `${d.server.count}/${d.server.maxPlayers}` : "" };
setServerPick(d.server);
renderNav();
}).catch(() => {});
route();
})();

View file

@ -0,0 +1,152 @@
// Helpers shared by the staff panel (admin.js) and the public site (public.js).
const $ = (sel, root = document) => root.querySelector(sel);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const MIN = 60 * 1000;
const ICON = {
search: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3 3"/></svg>',
plus: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>',
};
// api calls the panel's JSON API and throws an Error carrying the server's message on failure.
async function api(path, { method = "GET", body } = {}) {
const opts = { method, headers: { "X-Requested-With": "simpleadmin-web" }, credentials: "same-origin" };
if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
let res;
try {
res = await fetch(path, opts);
} catch {
throw new Error("Couldn't reach the panel. Check your connection.");
}
let data = null;
try { data = await res.json(); } catch { /* empty or non-JSON body */ }
if (!res.ok) throw new Error(data?.error || `The panel answered ${res.status}.`);
return data;
}
function qs(params) {
const u = new URLSearchParams();
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") u.set(k, v);
const s = u.toString();
return s ? `?${s}` : "";
}
function debounce(fn, ms) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
function fmtDuration(min) {
if (min === 0) return "Permanent";
if (min < 60) return `${min} min`;
if (min < 1440) return `${Math.round(min / 60)} h`;
const d = Math.round(min / 1440);
return d === 1 ? "1 day" : `${d} days`;
}
function fmtAgo(iso) {
const m = Math.round((Date.now() - new Date(iso).getTime()) / MIN);
if (m < 1) return "now";
if (m < 60) return `${m} min ago`;
if (m < 1440) return `${Math.round(m / 60)} h ago`;
const d = Math.round(m / 1440);
return d === 1 ? "yesterday" : `${d} days ago`;
}
function fmtLeft(min) {
if (min < 60) return `${Math.max(1, Math.round(min))} min left`;
if (min < 1440) return `${Math.round(min / 60)} h left`;
return `${Math.round(min / 1440)} d left`;
}
function fmtDate(iso) {
return new Date(iso).toLocaleString(undefined, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
}
// Minutes served and left on a timed penalty. With SimpleAdmin's online-only time mode, "passed"
// counts minutes served.
function served(p) {
if (p.passed != null && p.status === "active" && !p.ends) return { done: p.passed, left: p.duration - p.passed };
const start = new Date(p.created).getTime();
const end = p.ends ? new Date(p.ends).getTime() : start + p.duration * MIN;
const done = (Math.min(Date.now(), end) - start) / MIN;
return { done, left: (end - Date.now()) / MIN };
}
function rankTag(rank) {
if (!rank) return '<span class="faint">Player</span>';
return `<span class="rank" style="--c:${esc(rank.color)}"><i></i>${esc(rank.label)}</span>`;
}
function initial(name) { return esc(String(name || "").replace(/[^a-z0-9]/gi, "").charAt(0).toUpperCase() || "?"); }
// Time served against the full term: the bar under each length.
function termCell(p) {
const cls = ["term", p.kind === "comm" ? "comm" : ""];
let right;
let pct = 100;
if (p.duration === 0) {
cls.push("perm");
right = p.status === "lifted" ? "Lifted" : "Never expires";
if (p.status === "lifted") cls.push("lifted");
} else {
const s = served(p);
pct = Math.max(0, Math.min(100, (s.done / p.duration) * 100));
if (p.status === "active") right = fmtLeft(s.left);
else if (p.status === "expired") { right = "Served"; cls.push("done"); }
else { right = "Lifted"; cls.push("lifted"); }
}
const bar = p.duration === 0 ? "" : `<span style="width:${pct.toFixed(1)}%"></span>`;
return `<div class="${cls.join(" ")}">
<div class="term-top"><b>${fmtDuration(p.duration)}</b><span class="muted">${right}</span></div>
<div class="term-bar" role="img" aria-label="${esc(right)}">${bar}</div>
</div>`;
}
function statusTag(p) {
if (p.status === "active") return '<span class="tag red">Active</span>';
if (p.status === "expired") return '<span class="tag">Expired</span>';
return '<span class="tag green">Lifted</span>';
}
const COMM_LABEL = { GAG: "Gag", MUTE: "Mute", SILENCE: "Silence" };
const COMM_HELP = { GAG: "Text chat blocked", MUTE: "Voice blocked", SILENCE: "Text and voice blocked" };
function segmented(name, value, options) {
return `<div class="segmented" role="group" data-seg="${name}">
${options.map(([v, l]) => `<button type="button" data-value="${v}" aria-pressed="${v === value}">${l}</button>`).join("")}
</div>`;
}
function searchBox(placeholder, value) {
return `<label class="search">${ICON.search}<input type="search" id="q" placeholder="${esc(placeholder)}" value="${esc(value)}" aria-label="${esc(placeholder)}" autocomplete="off"></label>`;
}
function pager(list) {
const pages = Math.max(1, Math.ceil(list.total / list.pageSize));
if (pages <= 1) return "";
return `<div class="pager">
<button class="btn sm ghost" type="button" data-page="${list.page - 1}" ${list.page <= 1 ? "disabled" : ""}>Newer</button>
<span class="muted">Page ${list.page} of ${pages}</span>
<button class="btn sm ghost" type="button" data-page="${list.page + 1}" ${list.page >= pages ? "disabled" : ""}>Older</button>
</div>`;
}
function loading() { return '<div class="empty">Loading…</div>'; }
function errorBox(err) { return `<div class="empty">${esc(err.message)}</div>`; }
function closeDrawer() { $("#drawer-root").innerHTML = ""; }
function toast(msg) {
const root = $("#toast-root");
root.innerHTML = `<div class="toast">${esc(msg)}</div>`;
clearTimeout(toast.t);
toast.t = setTimeout(() => (root.innerHTML = ""), 3600);
}
const steamProfile = (sid) => `https://steamcommunity.com/profiles/${encodeURIComponent(sid)}`;

View file

@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bans and staff</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body class="public">
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="#server">
<svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
<path d="M16 2 28 7v9c0 7-5 12-12 14C9 28 4 23 4 16V7z" fill="none" stroke="#fe113d" stroke-width="2.5"/>
<path d="M11 16h10M16 11v10" stroke="#e6e8ee" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<span class="brand-name" id="site-name"></span>
</a>
<nav aria-label="Main"><ul class="tabs" id="nav-links"></ul></nav>
<a class="btn sm ghost signin" href="/admin"><span class="hide-xs">Staff sign in</span><span class="show-xs">Sign in</span></a>
</div>
</header>
<main id="view" class="public-main" tabindex="-1"></main>
<div id="drawer-root"></div>
<div id="toast-root" aria-live="polite"></div>
<script src="/static/common.js"></script>
<script src="/static/public.js"></script>
</body>
</html>

View file

@ -0,0 +1,264 @@
// Public, read-only site: the live server, bans, gags/mutes and staff. The API leaves out admin
// names 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);
return `<section class="panel side ${team === 3 ? "ct" : "t"}">
<div class="panel-head"><h2>${name}</h2><b class="side-score">${score}</b></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.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>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">
<span class="avatar">${initial(p.name)}</span>
<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>` : ""}`;
}
/* ---------- 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)}</span>${p.rank ? `<div>${rankTag(p.rank)}</div>` : ""}</div>
<button class="close" type="button" data-close aria-label="Close">×</button>
</div>
<section>
<h3>Right now</h3>
${now.length ? `<div class="chips">${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>`
: `<p class="muted" style="margin:0">${p.online ? "On the server, free to play and talk." : "Free to play and talk."}</p>`}
<p style="margin:12px 0 0"><a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">Steam profile</a></p>
</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); loadResults(); return; }
if (t.closest("[data-close]")) { closeDrawer(); 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();

View file

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Staff sign in</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body class="signin-page">
<main class="signin-card panel">
<svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
<path d="M16 2 28 7v9c0 7-5 12-12 14C9 28 4 23 4 16V7z" fill="none" stroke="#fe113d" stroke-width="2.5"/>
<path d="M11 16h10M16 11v10" stroke="#e6e8ee" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<h1>Staff panel</h1>
<p class="sub" id="signin-msg">Sign in with the Steam account that has your rank on the server.</p>
<p class="signin-error" id="signin-error" hidden></p>
<a class="btn primary" href="/auth/login">Sign in through Steam</a>
<a class="muted signin-back" href="/">Back to the public site</a>
</main>
<script src="/static/common.js"></script>
<script src="/static/signin.js"></script>
</body>
</html>

View file

@ -0,0 +1,13 @@
// Explains why someone landed on the sign-in page: a failed Steam sign-in, or an account without a
// staff rank.
const err = new URLSearchParams(location.search).get("signin");
if (err) {
const box = $("#signin-error");
box.textContent = `Steam sign-in didn't work: ${err.replace(/^steam: /, "")}`;
box.hidden = false;
}
api("/api/me").then((me) => {
if (me.signedIn && !me.staff) {
$("#signin-msg").textContent = `You're signed in as ${me.steamid}, but that account has no staff rank on this server. Sign in with a different Steam account, or ask an admin to add you.`;
}
}).catch(() => {});

File diff suppressed because it is too large Load diff