// 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 `
${esc(a.admin)} ${verb} ${esc(a.name || a.steamid)} ${len}
${fmtAgo(a.at)}${a.reason ? `, ${esc(a.reason)}` : ""} `;
}
function liveHeader(s) {
if (!s.online) {
return `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.
Offline
`;
}
return `
${esc(s.hostname)}${s.warmup ? ", warmup" : ""}
${esc(s.map)}
${s.count}/${s.maxPlayers} Players
${s.scoreT} : ${s.scoreCt} ${s.warmup ? "Warmup, no rounds yet" : "Rounds won, T vs CT"}
${s.staffOnline} Staff online
`;
}
function tagsFor(p) {
return (p.tags || []).map((t) => {
if (t.startsWith("WARN:")) {
const n = t.slice(5);
return `${esc(n)} warn${n === "1" ? "" : "s"} `;
}
return `${COMM_LABEL[t]} `;
}).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 'Nobody is on the server.
';
const teamName = { 2: "T", 3: "CT" };
return `
Player Rank K / D Ping Actions
${list.map((p) => `
${teamName[p.team] || "SPEC"} ${esc(p.name)} ${tagsFor(p)}
${rankTag(p.rank)}
${p.kills} / ${p.deaths}
${p.ping}
${p.canTarget ? `
${can("@css/chat") ? `Gag ` : ""}
${can("@css/kick") ? `Kick ` : ""}
${can("@css/ban") ? `Ban ` : ""}` : ""}
`).join("")}
`;
}
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)}
On the server
${can("@css/changemap") ? 'Change map ' : ""}
${can("@css/chat") ? 'Message all ' : ""}
${s.online ? onlineTable(s.players) : '
Offline.
'}
${d.activity.length ? `${d.activity.map(activityLine).join("")} ` : 'Nothing yet.
'}
Ending soon ${d.activeBans} bans, ${d.activeComms} blocks active
${d.expiring.length ? `${d.expiring.map((p) => `${esc(p.name || p.steamid)} ${p.kind === "ban" ? "ban" : COMM_LABEL[p.type].toLowerCase()} ${fmtLeft(served(p).left)} `).join("")} ` : 'No timed bans or blocks.
'}
`;
}
function setServerPick(s) {
const el = $("#server-pick");
el.innerHTML = s.online
? ` ${esc(s.hostname)}${esc(s.map)}, ${s.count}/${s.maxPlayers} players `
: `Server offline Live status unavailable `;
}
/* ---------- Players ---------- */
function players() {
$("#view").innerHTML = `
Players Everyone SimpleAdmin has seen join, with their rank and record. Search by name, SteamID64${can("@css/showip") ? " or IP address" : ""}.
${searchBox("Search players", state.query)}
${segmented("playerFilter", state.playerFilter, [["online", "Online"], ["ranked", "Ranked"], ["all", "All"]])}
${loading()}
`;
loadResults();
}
function playersTable(list) {
if (!list.items.length) {
if (state.query) return `No players match "${esc(state.query)}". Try a SteamID64 or part of a name.
`;
return `${state.playerFilter === "online" ? "Nobody is on the server." : "No players yet."}
`;
}
return `
Player Rank Record Last seen
${list.items.map((p) => `
${initial(p.name)} ${esc(p.name || p.steamid)} ${esc(p.steamid)}
${rankTag(p.rank)}
${p.banned ? 'Banned ' : ""}
${p.bans ? `${p.bans} ban${p.bans > 1 ? "s" : ""} ` : ""}
${p.comms ? `${p.comms} block${p.comms > 1 ? "s" : ""} ` : ""}
${p.warns ? `${p.warns} warn${p.warns > 1 ? "s" : ""} ` : ""}
${!p.bans && !p.comms && !p.warns ? 'Clean ' : ""}
${p.online ? 'Online ' : p.lastSeen ? `${fmtAgo(p.lastSeen)} ` : 'Never joined '}
`).join("")}
${pager(list)}`;
}
/* ---------- Bans and comms ---------- */
function punishTable(list, kind) {
if (!list.items.length) {
return `${state.query ? `Nothing matches "${esc(state.query)}".` : kind === "ban" ? "No bans in this view." : "No gags or mutes in this view."}
`;
}
const canLift = kind === "ban" ? can("@css/unban") : can("@css/chat");
const canAdd = kind === "ban" ? can("@css/ban") : can("@css/chat");
return `
Player
${kind === "comm" ? "Type " : ""}
Reason
Length
Issued
Status
Actions
${list.items.map((p) => `
${kind === "comm" ? `${COMM_LABEL[p.type]} ` : ""}
${esc(p.reason)}${p.liftedBy || p.liftReason ? `Lifted${p.liftedBy ? ` by ${esc(p.liftedBy)}` : ""}${p.liftReason ? `: ${esc(p.liftReason)}` : ""} ` : ""}
${termCell(p)}
${fmtDate(p.created)} by ${esc(p.admin || "Console")}
${statusTag(p)}
${p.status === "active" && canLift && p.steamid ? `${kind === "ban" ? "Unban" : "Lift"} ` : ""}
${p.status !== "active" && canAdd && p.steamid ? `Reapply ` : ""}
`).join("")}
${pager(list)}`;
}
function bans() {
$("#view").innerHTML = `
Bans The bar under each length shows how much of the ban has been served. Striped bars never expire.
${can("@css/ban") ? `
${ICON.plus}Add ban ` : ""}
${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"]])}
${loading()}
`;
loadResults();
}
function comms() {
$("#view").innerHTML = `
Gags & mutes A gag blocks text chat, a mute blocks voice, and a silence blocks both.
${can("@css/chat") ? `
${ICON.plus}Add gag or mute ` : ""}
${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"]])}
${loading()}
`;
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) => `
${esc(x.label)} ${x.members.length} ${x.members.length === 1 ? "member" : "members"} ${x.immunity}
`;
const staffGroups = d.groups.filter((x) => !x.supporter);
const supporters = d.groups.filter((x) => x.supporter);
const memberRow = (p, groupName) => `
${initial(p.name)} ${esc(p.name)} ${esc(p.steamid)}
${p.online ? 'Online ' : p.lastSeen ? `Seen ${fmtAgo(p.lastSeen)} ` : 'Never joined '}
${p.ends ? `Until ${fmtDate(p.ends)}` : "No expiry"}
${manage && p.steamid !== d.me ? `Change rank ` : ""}
`;
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 = `
Staff & ranks 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."}
${manage ? `
${ICON.plus}New rank
${ICON.plus}Add staff
` : ""}
${d.groups.length ? `
${staffGroups.map(ladderItem).join("")}
${supporters.length ? `Supporter ranks ${supporters.map(ladderItem).join("")}` : ""}
${esc(g.label)}
${esc(g.name)}
Members ${g.members.length} ${g.members.length === 1 ? "person" : "people"}
${g.members.length ? `
${g.members.map((p) => memberRow(p, g.name)).join("")}
`
: `
Nobody has this rank yet.${manage ? " Use Add staff to give it to someone." : ""}
`}
${d.unranked.length ? `
Admins without a rank Permissions set directly in SimpleAdmin
${d.unranked.map((p) => memberRow(p, "")).join("")}
` : ""}
` : `SimpleAdmin has no ranks for this server yet.${manage ? " Use New rank to create one." : ""}
`}`;
}
/* ---------- 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 ` `;
if (c.type === "int") return ` `;
return ` `;
};
const saControl = (s) => {
const val = sa.values[s.key];
const dis = d.canRoot ? "" : "disabled";
if (s.type === "bool") return ` `;
if (s.type === "select") return `${s.options.map((o) => `${esc(o.label)} `).join("")} `;
return ` `;
};
const row = (label, help, key, control) => `
${esc(label)} ${help ? `
${esc(help)}
` : ""}${key ? `
${esc(key)}` : ""}
${control}
`;
v.innerHTML = `
Server settings Server changes go live over RCON. SimpleAdmin's settings are saved to its config file.
Server Live over RCON. Resets when the server restarts.
${!d.canCvar ? 'Your rank can\'t change server settings.
'
: !d.server.online ? 'The game server isn\'t answering, so these can\'t be read or changed right now.
'
: (d.cvars || []).map((c) => row(c.label, c.help, c.key, cvarControl(c))).join("")}
Punishments ${sa.available ? `${sa.path ? `Saved to ${esc(sa.path)}. ` : ""}Applies after the next server restart.` : "Read-only"}
${!sa.available ? 'The panel wasn\'t given CS2-SimpleAdmin.json (SAW_SA_CONFIG), so these can\'t be shown or changed here.
'
: sa.error ? `${esc(sa.error)}
`
: sa.settings.map((s) => row(s.label, s.help, s.key, saControl(s))).join("")}
Connections
${row("Database", "SimpleAdmin's MySQL database.", "", d.database.connected ? 'Connected ' : 'Not connected ')}
${row("Game server", d.server.online ? "Live status through the WebPanelBridge plugin." : d.server.error || "", "", d.server.online ? 'Connected ' : 'Not answering ')}
Maintenance Each of these asks first.
${can("@css/root") ? row("Reload admins", "Re-read ranks and staff from the database. Runs css_reloadadmins.", "", 'Reload admins ') : ""}
${can("@css/generic") ? row("Restart the match", "Restarts the game on the current map. Runs css_rr.", "", 'Restart match ') : ""}
${can("@css/changemap") ? row("Change map", "Switch to another map, or a Workshop map with ws:.", "", 'Change map ') : ""}
You have unsaved changes.
Discard Save changes
`;
}
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 = `
`;
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)
? `${label} ` : "";
const manage = can("@css/root") && p.steamid !== state.me.steamid;
$("#drawer-root").innerHTML = `
${initial(name)}
${esc(name)} ${rankTag(d.rank)} ${d.online ? 'Online ' : ""}
×
${t ? "" : `${p.steamid === state.me.steamid ? "This is you." : "This player's immunity is higher than yours, so you can't act on them."}
`}
${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") : ""}
Details
SteamID64 ${esc(p.steamid)}
Profile Steam profile
${p.ips ? `Known IPs ${p.ips.map(esc).join(", ") || 'None '} ` : ""}
Known names ${(p.names || []).map(esc).join(", ") || 'None '}
Last seen ${d.online ? "On the server now" : p.lastSeen ? fmtAgo(p.lastSeen) : "Never joined"}
${d.immunity ? `Immunity ${d.immunity} ` : ""}
${d.adminEnds ? `Rank expires ${fmtDate(d.adminEnds)} ` : ""}
${manage ? `
Rank
${d.rank ? "Change rank" : "Give a rank"}
` : ""}
History
${history.length ? `${history.map((h) => `
${h.label} by ${esc(h.admin || "Console")}, ${fmtAgo(h.created)} ${statusTag(h)}
${esc(h.reason)}
${h.kind === "warn" ? "" : termCell(h)}
`).join("")} ` : 'No bans, gags, mutes or warnings on record.
'}
`;
$(".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(`
`);
}
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) => `${esc(g.label)} (${g.immunity}) `).join("");
openModal(`
${current ? "Change rank" : "Add staff"} ×
Player
${name ? `${esc(name)} ` : ""}
Rank ${current ? 'No rank (remove from staff) ' : ""}${opts}
Expires Never In 7 days In 30 days In 90 days Trial ranks usually expire.
`);
}
function newGroupDialog() {
openModal(`
New rank ×
`);
}
function textDialog(kind) {
const cfg = {
map: { title: "Change map", label: "Map", placeholder: "de_mirage, or ws:", 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(`
${c.title}${data.name ? `: ${esc(data.name)}` : ""} ×
${c.label}
`);
}
function confirmDialog(title, body, label, run) {
confirmDialog.run = run;
openModal(`
${title} ×
${body}
${confirmDialog.reason ? '
Reason ' : ""}
`);
}
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) => `${p.label}${count[p.id] !== undefined && count[p.id] !== "" ? `${esc(count[p.id])} ` : ""} `).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 ${esc(d.name || "this player")} ? They can join again straight away. This lifts every active ban on their SteamID.`
: `Lift the ${COMM_LABEL[d.type].toLowerCase()} on ${esc(d.name || "this player")} ?`,
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 ${esc(d.name)} ? 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))}. ${esc(name.slice(i + 1))}` : esc(name);
});
$("#me").innerHTML = `${initial(state.me.name)}
${esc(state.me.name)} ${esc(state.me.rank?.label || "Staff")}
Sign out `;
// 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();
})();