Console panel, collapsible panels, Add on the Members panel

- Server settings > Console sends a raw console command for staff with @css/rcon
  (POST /api/admin/rcon). It doesn't wait for the server; the output is only logged.
- Staff & ranks and Server settings panels fold up by clicking their heads. All but
  Ranks and Server start folded; a folded Ranks panel shows the selected rank.
- The Members panel has an Add button that opens Add staff with that rank picked.
- The public rank log shows a rank being given as Promoted and taken away as Demoted.
This commit is contained in:
Astra 2026-10-03 12:09:43 +01:00
parent 31901495de
commit b26360e8f3
8 changed files with 123 additions and 24 deletions

View file

@ -23,6 +23,8 @@ const state = {
query: "",
num: 1,
group: null,
// Collapsed panels, see foldHead.
fold: { ranks: false, rank: true, members: true, sa: true, connections: true, maintenance: true, rcon: true },
me: null,
counts: {},
};
@ -274,6 +276,15 @@ async function loadResults() {
}
}
/* ---------- Collapsible panels ---------- */
// A collapsible panel's head: clicking anywhere on it but its own buttons opens or closes it.
// state.fold remembers which are closed until the page is reloaded; a panel not in it starts open.
const foldHead = (key, title, aside = "") => `<div class="panel-head fold-head" data-fold="${key}">
<h2><button class="fold-toggle" type="button" aria-expanded="${!state.fold[key]}">${ICON.chevron}${title}</button></h2>${aside}
</div>`;
const folded = (key) => (state.fold[key] ? " folded" : "");
/* ---------- Staff & ranks ---------- */
async function staff() {
@ -313,8 +324,8 @@ async function staff() {
</div>
${d.groups.length ? `<div class="ranks-layout">
<section class="panel">
<div class="panel-head"><h2>Ranks</h2><p>Immunity</p></div>
<section class="panel${folded("ranks")}">
${foldHead("ranks", "Ranks", `<p class="fold-open">Immunity</p><p class="fold-shut"><span class="rank" style="--c:${esc(g.color)}"><i></i>${esc(g.label)}</span></p>`)}
<ul class="ladder">
${staffGroups.map(ladderItem).join("")}
${supporters.length ? `<li class="sep">Supporter ranks</li>${supporters.map(ladderItem).join("")}` : ""}
@ -322,12 +333,9 @@ async function staff() {
</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}">
<section class="panel${folded("rank")}">
${foldHead("rank", `<span class="rank" style="--c:${esc(g.color)};font:inherit"><i></i>${esc(g.label)}</span>`, `<span class="sid">${esc(g.name)}</span>`)}
<form class="panel-body rank-form" id="group-form" 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>
@ -350,8 +358,8 @@ async function staff() {
</form>
</section>
<section class="panel">
<div class="panel-head"><h2>Members</h2><p>${g.members.length} ${g.members.length === 1 ? "person" : "people"}</p></div>
<section class="panel${folded("members")}">
${foldHead("members", "Members", `<div style="display:flex;align-items:center;gap:12px"><p>${g.members.length} ${g.members.length === 1 ? "person" : "people"}</p>${manage ? `<button class="btn sm" type="button" data-act="add-staff" data-group="${esc(g.name)}">${ICON.plus}Add</button>` : ""}</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>`}
@ -540,28 +548,36 @@ async function settings() {
<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>
<section class="panel${folded("cvars")}" id="cvars">
${foldHead("cvars", "Server", "<p>Live over RCON. Resets when the server restarts.</p>")}
${!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>
<section class="panel${folded("sa")}" id="sa">
${foldHead("sa", "Punishments", `<p>${sa.available ? `${sa.path ? `Saved to ${esc(sa.path)}. ` : ""}Applies after the next server restart.` : "Read-only"}</p>`)}
${!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>
${d.canRcon ? `<section class="panel${folded("rcon")}">
${foldHead("rcon", "Console", "<p>Runs as the server console. Doesn't wait for the server's reply.</p>")}
<form class="panel-body rcon-form" id="rcon-form" autocomplete="off">
<input class="input" name="command" maxlength="512" required placeholder="Console command, like mp_restartgame 1" aria-label="Console command" spellcheck="false">
<button class="btn primary" type="submit">Send</button>
</form>
</section>` : ""}
<section class="panel${folded("connections")}">
${foldHead("connections", "Connections")}
${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>
<section class="panel${folded("maintenance")}">
${foldHead("maintenance", "Maintenance", "<p>Each of these asks first.</p>")}
${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>') : ""}
@ -709,12 +725,13 @@ function punishDialog(kind, { sid = "", name = "", type = "GAG", reason = "" } =
</form>`);
}
async function rankDialog(sid, name, current) {
// preset picks the rank for someone new (current is their rank now, "" if they have none).
async function rankDialog(sid, name, current, preset = "") {
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("");
const opts = (groups || []).map((g) => `<option value="${esc(g.name)}" ${g.name === (current || preset) ? "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>
@ -878,6 +895,15 @@ document.addEventListener("click", (e) => {
return;
}
const fold = t.closest("[data-fold]");
if (fold && !t.closest("[data-act]")) {
const shut = !state.fold[fold.dataset.fold];
state.fold[fold.dataset.fold] = shut;
fold.closest(".panel").classList.toggle("folded", shut);
$(".fold-toggle", fold).setAttribute("aria-expanded", String(!shut));
return;
}
const grp = t.closest("[data-group]:not([data-act])");
if (grp) { state.group = Number(grp.dataset.group); staff(); return; }
@ -906,7 +932,7 @@ document.addEventListener("click", (e) => {
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 "add-staff": return rankDialog("", "", "", d.group || "");
case "new-group": return newGroupDialog();
case "delete-group":
confirmDialog.reason = false;
@ -957,6 +983,16 @@ document.addEventListener("submit", async (e) => {
return;
}
if (form.id === "rcon-form") {
// Not act(): re-rendering the page would throw away unsaved settings above.
try {
const res = await api("/api/admin/rcon", { method: "POST", body: { command: f.command.value } });
toast(res.message || "Sent.");
f.command.value = "";
} catch (err) { toast(err.message); }
return;
}
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 });