simpleadmin-web/internal/web/assets/admin.js
Astra b579941964 Scoreboard: label warmup and round scores, count players and bots per team
The score caption says "Warmup, no rounds yet" during warmup and "Rounds
won, T vs CT" otherwise. Each team panel on the public page shows its
player and bot count, and its score as rounds won, or a Warmup label
during warmup. The top navigation scrolls sideways on narrow screens
instead of overflowing.
2026-09-27 15:37:37 +01:00

835 lines
44 KiB
JavaScript
Raw Permalink 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.

// 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>${s.warmup ? "Warmup, no rounds yet" : "Rounds won, 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();
})();