Busiest hours and the heatmap count the different players on T or CT in each hour, open segments included, instead of averaging player-hours, so one player for part of an hour is 1, not 0.38. Their descriptions end in the span covered (Last 30 days, or Since <date>). Side-by-side chart panels match heights, and Players per day notes its busiest day. Dropped the page descriptions on Bans, Gags & mutes, Staff and Playtime, and the Verified badges on the staff Overview scoreboard and drawer header; the Verification page and the drawer's Verification section still show it. The GroupVerify DB test now checks the staff drawer, since the public site says nothing about verification.
1211 lines
66 KiB
JavaScript
1211 lines
66 KiB
JavaScript
// 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" },
|
||
// Only when the panel has PlaytimeHistory's database (playtime.js).
|
||
{ id: "playtime", label: "Playtime", show: () => !!state.me?.playtimeStaff },
|
||
{ id: "staff", label: "Staff & ranks", flag: "@zio/webpanel" },
|
||
// Only when GroupVerify's tables are in the database.
|
||
{ id: "verify", label: "Verification", show: () => !!state.me?.verify },
|
||
{ id: "settings", label: "Server settings" },
|
||
{ id: "notifications", label: "Notifications" },
|
||
];
|
||
|
||
const pageAllowed = (p) => (!p.flag || can(p.flag)) && (!p.show || p.show());
|
||
|
||
const state = {
|
||
page: "overview",
|
||
banFilter: "active",
|
||
commFilter: "active",
|
||
playerFilter: "online",
|
||
verifyView: "overrides",
|
||
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: {},
|
||
};
|
||
|
||
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 === "verify") {
|
||
path = "/api/admin/verify"; status = undefined; render = verifyResults;
|
||
} 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,
|
||
view: state.page === "verify" ? state.verifyView : 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);
|
||
}
|
||
}
|
||
|
||
/* ---------- 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() {
|
||
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">${avatar(p.steamid, p.name)}<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${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("")}` : ""}
|
||
</ul>
|
||
</section>
|
||
|
||
<div class="stack">
|
||
<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>
|
||
</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${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>`}
|
||
</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>`}`;
|
||
}
|
||
|
||
/* ---------- Verification (GroupVerify) ---------- */
|
||
|
||
const VERIFY_DAYS = [[1, "1 day"], [7, "1 week"], [30, "30 days"], [90, "90 days"], [365, "1 year"], [0, "No expiry"]];
|
||
|
||
function verify() {
|
||
$("#view").innerHTML = `
|
||
<div class="page-head">
|
||
<div><h1>Verification</h1><p class="sub">GroupVerify verifies players who are in the server's Steam group, or have an override given here. Other plugins give verified players their perks.</p></div>
|
||
<div id="verify-actions"></div>
|
||
</div>
|
||
<div class="stack" id="verify-status">${loading()}</div>
|
||
<section class="panel" style="margin-top:18px">
|
||
<div class="toolbar">
|
||
${searchBox("Search by name or SteamID64", state.query)}
|
||
${segmented("verifyView", state.verifyView, [["overrides", "Overrides"], ["group", "Confirmed group members"]])}
|
||
</div>
|
||
<div class="table-wrap" id="results">${loading()}</div>
|
||
</section>`;
|
||
loadResults();
|
||
}
|
||
|
||
const PLUGIN_STATE = {
|
||
loaded: (v) => `<span class="tag green">Loaded</span> <span class="muted">${esc(v)}</span>`,
|
||
missing: () => '<span class="tag red">Not loaded</span>',
|
||
unknown: () => '<span class="tag">Unknown</span> <span class="muted">the game server isn\'t answering</span>',
|
||
};
|
||
|
||
// verifyStatus is the top of the page, drawn once per visit so typing in the group field survives
|
||
// searches and paging.
|
||
function verifyStatus(st) {
|
||
const box = $("#verify-status");
|
||
if (!box || box.dataset.drawn) return;
|
||
box.dataset.drawn = "1";
|
||
$("#verify-actions").innerHTML = st.canOverride ? `<button class="btn primary" type="button" data-act="override">${ICON.plus}Give override</button>` : "";
|
||
const warn = [];
|
||
if (st.plugin === "missing") warn.push("GroupVerify isn't loaded on the game server, so nobody is verified in game, and the public site doesn't show verification.");
|
||
if (!st.enabled) warn.push("Verification is turned off: nobody is verified, overrides included.");
|
||
else if (!st.groupId) warn.push("No Steam group is set, so verification is off: nobody is verified, overrides included.");
|
||
const group = st.groupId
|
||
? `<a href="https://steamcommunity.com/gid/${esc(st.groupId)}" target="_blank" rel="noopener">${esc(st.groupId)}</a>`
|
||
: '<span class="faint">Not set</span>';
|
||
box.innerHTML = `
|
||
${warn.map((w) => `<div class="panel"><div class="panel-body"><p style="margin:0"><span class="tag amber">Off</span> ${esc(w)}</p></div></div>`).join("")}
|
||
<section class="panel">
|
||
<div class="panel-head"><h2>GroupVerify</h2>${st.active && st.plugin !== "missing" ? '<span class="tag green">On</span>' : '<span class="tag amber">Off</span>'}</div>
|
||
<div class="panel-body">
|
||
<dl class="kv">
|
||
<dt>Plugin</dt><dd>${PLUGIN_STATE[st.plugin](st.version)}</dd>
|
||
<dt>Steam group</dt><dd>${group}</dd>
|
||
<dt>Clan tag</dt><dd>${st.clanTag ? `<b>${esc(st.clanTag)}</b> <span class="muted">for verified players without a rank; unverified ones get none</span>` : '<span class="faint">None: players keep their own tags</span>'}</dd>
|
||
<dt>Group members</dt><dd>${st.groupMembers}${st.groupFetched ? ` <span class="muted">last confirmed with Steam ${fmtAgo(st.groupFetched)}</span>` : ' <span class="faint">nobody confirmed yet</span>'}</dd>
|
||
<dt>Overrides</dt><dd>${st.overrides}</dd>
|
||
<dt>Changes apply</dt><dd>Within ${st.reloadSeconds} seconds <span class="muted">(times in ${esc(st.timezone)})</span></dd>
|
||
</dl>
|
||
${st.canConfigure ? `<form id="gv-form" style="margin-top:16px">
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
|
||
<label class="field" style="flex:2 1 240px;min-width:0"><span>Steam group ID64</span>
|
||
<input class="input" name="group" value="${esc(st.groupId)}" data-was="${esc(st.groupId)}" inputmode="numeric" pattern="[0-9]{0,20}" placeholder="103582791…, blank or 0 turns it off">
|
||
</label>
|
||
<label class="field" style="flex:1 1 140px;min-width:0"><span>Clan tag</span>
|
||
<input class="input" name="clantag" value="${esc(st.clanTag)}" data-was="${esc(st.clanTag)}" maxlength="12" pattern="[^";\\\\]*" placeholder="none">
|
||
</label>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn" type="submit">Save</button>
|
||
<button class="btn ${st.enabled ? "danger" : "primary"}" type="button" data-act="gv-toggle" data-on="${st.enabled ? "0" : "1"}">${st.enabled ? "Turn off" : "Turn on"}</button>
|
||
</div>
|
||
</div>
|
||
<small class="muted" style="display:block;margin-top:8px;font-size:13px;overflow-wrap:anywhere">The <groupID64> from your group's member list XML.</small>
|
||
</form>` : '<p class="muted" style="margin:16px 0 0">Only Root can change the group or turn verification on and off.</p>'}
|
||
</div>
|
||
</section>`;
|
||
}
|
||
|
||
function verifyResults(d) {
|
||
verifyStatus(d.status);
|
||
const list = d.list;
|
||
const groupView = state.verifyView === "group";
|
||
if (!list.items.length) {
|
||
if (state.query) return `<div class="empty">Nobody matches "${esc(state.query)}".</div>`;
|
||
return `<div class="empty">${groupView ? "Nobody confirmed yet. GroupVerify checks players with Steam as they join." : "No overrides."}</div>`;
|
||
}
|
||
const mayEdit = (sid) => d.status.canOverride && (sid !== state.me.steamid || can("@css/root"));
|
||
const who = (e) => `<td><div class="who"><div><b><a href="#" data-player="${esc(e.steamid)}">${e.name ? esc(e.name) : '<span class="faint">Never joined</span>'}</a></b><span class="sid">${esc(e.steamid)}</span></div></div></td>`;
|
||
if (groupView) {
|
||
return `<table>
|
||
<thead><tr><th>Player</th><th>First confirmed</th><th class="hide-sm">Last confirmed</th></tr></thead>
|
||
<tbody>${list.items.map((e) => `<tr>${who(e)}<td>${fmtDate(e.created)}</td><td class="hide-sm muted">${fmtAgo(e.seen)}</td></tr>`).join("")}</tbody>
|
||
</table>${pager(list)}`;
|
||
}
|
||
return `<table>
|
||
<thead><tr><th>Player</th><th>Ends</th><th class="hide-sm">Given</th><th><span class="sr">Actions</span></th></tr></thead>
|
||
<tbody>
|
||
${list.items.map((e) => `<tr>
|
||
${who(e)}
|
||
<td>${e.expires ? `${fmtDate(e.expires)}<br><small class="muted">${fmtLeft((new Date(e.expires) - Date.now()) / MIN)}</small>` : "Never"}</td>
|
||
<td class="hide-sm">${fmtDate(e.created)}${e.addedBy ? `<br><small class="muted">by ${esc(e.addedBy)}</small>` : ""}</td>
|
||
<td><div class="row-actions">${mayEdit(e.steamid) ? `
|
||
<button class="btn sm ghost" type="button" data-act="override" data-sid="${esc(e.steamid)}" data-name="${esc(e.name)}">Change</button>
|
||
<button class="btn sm danger" type="button" data-act="remove-override" data-sid="${esc(e.steamid)}" data-name="${esc(e.name)}">Remove</button>` : ""}
|
||
</div></td>
|
||
</tr>`).join("")}
|
||
</tbody>
|
||
</table>${pager(list)}`;
|
||
}
|
||
|
||
// verifySection is the Verification part of a player's drawer.
|
||
function verifySection(v, sid, name) {
|
||
if (!v) return "";
|
||
const o = v.override;
|
||
const badge = !v.active ? '<span class="tag amber">Verification is off</span>'
|
||
: v.verified ? VERIFIED_TAG : '<span class="tag">Not verified</span>';
|
||
return `<section>
|
||
<h3>Verification</h3>
|
||
<p style="margin:0 0 10px">${badge}</p>
|
||
<dl class="kv">
|
||
<dt>Steam group</dt><dd>${v.inGroup ? `Member <span class="muted">confirmed with Steam ${fmtAgo(v.groupSeen)}</span>` : '<span class="faint">Not confirmed as a member</span>'}</dd>
|
||
<dt>Override</dt><dd>${o ? `${o.expires ? `Until ${fmtDate(o.expires)}` : "No expiry"}${o.addedBy ? ` <span class="muted">from ${esc(o.addedBy)}</span>` : ""}` : '<span class="faint">None</span>'}</dd>
|
||
</dl>
|
||
${v.canOverride ? `<div style="display:flex;gap:8px;margin-top:12px">
|
||
<button class="btn" type="button" data-act="override" data-sid="${esc(sid)}" data-name="${esc(name)}">${o ? "Change override" : "Give override"}</button>
|
||
${o ? `<button class="btn danger" type="button" data-act="remove-override" data-sid="${esc(sid)}" data-name="${esc(name)}">Remove override</button>` : ""}
|
||
</div>` : ""}
|
||
</section>`;
|
||
}
|
||
|
||
function overrideDialog(sid, name) {
|
||
openModal(`
|
||
<form method="dialog" data-submit="override">
|
||
<div class="modal-head"><h2>${sid ? "Override" : "Give override"}${name ? `: ${esc(name)}` : ""}</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>They count as verified whether or not they're in the Steam group, until it ends.</small>
|
||
</label>
|
||
<div class="field"><span>Length</span>
|
||
<div class="chips" data-chips="days">${VERIFY_DAYS.map(([v, l]) => `<button type="button" class="chip-btn" data-value="${v}" aria-pressed="${v === 30}">${l}</button>`).join("")}</div>
|
||
<small>Changing an override sets a new end from now.</small>
|
||
</div>
|
||
</div>
|
||
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">Save override</button></div>
|
||
</form>`);
|
||
}
|
||
|
||
/* ---------- Notifications ---------- */
|
||
|
||
// Push notifications to this staff member's devices (internal/web/notify.go, sw.js). Preferences
|
||
// apply to all their devices and save as soon as they change.
|
||
|
||
const b64urlToBytes = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
|
||
const bytesToB64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||
const browserTimezone = () => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; } };
|
||
const pushSupported = () => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||
|
||
async function sha256Hex(text) {
|
||
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
||
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||
}
|
||
|
||
// This browser's push subscription, or null.
|
||
async function pushSubscription() {
|
||
const reg = await navigator.serviceWorker.getRegistration();
|
||
return reg ? reg.pushManager.getSubscription() : null;
|
||
}
|
||
|
||
async function notifications() {
|
||
const v = $("#view");
|
||
let d;
|
||
try { d = await api("/api/admin/notifications"); } catch (err) { v.innerHTML = errorBox(err); return; }
|
||
let here = false;
|
||
if (d.available && pushSupported() && window.crypto?.subtle) {
|
||
try {
|
||
const sub = await pushSubscription();
|
||
here = !!sub && d.devices.includes(await sha256Hex(sub.endpoint));
|
||
} catch { /* treat as off */ }
|
||
}
|
||
if (state.page !== "notifications") return;
|
||
state.notif = d;
|
||
const p = d.prefs;
|
||
// The count row is only worth showing when there's a device besides this one.
|
||
const total = d.devices.length;
|
||
const others = total - (here ? 1 : 0);
|
||
const row = (label, help, control) => `<div class="setting">
|
||
<div><b>${label}</b>${help ? `<p>${help}</p>` : ""}</div>
|
||
<div class="control">${control}</div>
|
||
</div>`;
|
||
|
||
let device;
|
||
if (!d.available) device = row("This device", "Notifications aren't set up on the panel. Its log says why.", '<span class="tag red">Unavailable</span>');
|
||
else if (!pushSupported()) device = row("This device", "This browser can't get notifications. On an iPhone or iPad, add the site to your home screen and open it from there.", '<span class="tag red">Not supported</span>');
|
||
else if (Notification.permission === "denied") device = row("This device", "Notifications are blocked for this site. Allow them in the browser's site settings (or the app's notification settings), then come back.", '<span class="tag red">Blocked</span>');
|
||
else if (here) device = row("This device", "Notifications are on here.", '<div style="display:flex;gap:8px"><button class="btn" type="button" data-act="notif-test">Send a test</button><button class="btn" type="button" data-act="notif-off">Turn off</button></div>');
|
||
else device = row("This device", "Turn notifications on to get the ones picked below here, even with the panel closed.", '<button class="btn primary" type="button" data-act="notif-on">Turn on</button>');
|
||
|
||
const dis = d.available ? "" : "disabled";
|
||
v.innerHTML = `
|
||
<div class="page-head">
|
||
<div><h1>Notifications</h1><p class="sub">Get bans, gags, mutes and server alerts on your phone or computer. Nothing about your own actions is sent to you.</p></div>
|
||
</div>
|
||
<div class="settings">
|
||
<section class="panel">
|
||
<div class="panel-head"><div><h2>Devices</h2></div></div>
|
||
${device}
|
||
${others > 0 ? row("Your devices", `${total} ${total === 1 ? "device has" : "devices have"} notifications on${here ? ", this one included" : ", not this one"}.`, '<button class="btn" type="button" data-act="notif-off-all">Turn off everywhere</button>') : ""}
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-head"><div><h2>Quiet hours</h2><p>Nothing is sent between these times, in your timezone (${esc(browserTimezone())}).</p></div></div>
|
||
${row("Quiet hours", "", `<button class="switch" type="button" role="switch" aria-checked="${p.quiet.enabled}" aria-label="Quiet hours" data-notif="quiet" ${dis}></button>`)}
|
||
${row("From and until", "", `<div class="quiet-times"><input class="input" type="time" id="quiet-start" value="${esc(p.quiet.start)}" aria-label="Quiet from" data-notif-time ${dis}><span class="muted">to</span><input class="input" type="time" id="quiet-end" value="${esc(p.quiet.end)}" aria-label="Quiet until" data-notif-time ${dis}></div>`)}
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-head"><div><h2>What to send</h2><p>For all your devices.</p></div></div>
|
||
${d.types.map((t) => row(esc(t.label) + (t.ready ? "" : ' <span class="tag">Coming later</span>'), t.help ? esc(t.help) : "",
|
||
`<button class="switch" type="button" role="switch" aria-checked="${t.ready && p.types.includes(t.key)}" aria-label="${esc(t.label)}" ${t.ready ? `data-ntype="${esc(t.key)}" ${dis}` : "disabled"}></button>`)).join("")}
|
||
</section>
|
||
</div>`;
|
||
}
|
||
|
||
let notifSave;
|
||
function saveNotifPrefs() {
|
||
const types = [...document.querySelectorAll("[data-ntype]")].filter((s) => s.getAttribute("aria-checked") === "true").map((s) => s.dataset.ntype);
|
||
const body = {
|
||
types,
|
||
quiet: { enabled: $("[data-notif=quiet]").getAttribute("aria-checked") === "true", start: $("#quiet-start").value, end: $("#quiet-end").value },
|
||
timezone: browserTimezone(),
|
||
};
|
||
// Several quick toggles make one save.
|
||
clearTimeout(notifSave);
|
||
notifSave = setTimeout(async () => {
|
||
try { await api("/api/admin/notifications/prefs", { method: "PUT", body }); toast("Saved."); } catch (err) { toast(err.message); }
|
||
}, 400);
|
||
}
|
||
|
||
async function enableNotifications() {
|
||
const d = state.notif;
|
||
try {
|
||
if (await Notification.requestPermission() !== "granted") {
|
||
toast("Notifications weren't allowed.");
|
||
notifications();
|
||
return;
|
||
}
|
||
if (!(await navigator.serviceWorker.getRegistration())) await navigator.serviceWorker.register("/sw.js");
|
||
const reg = await navigator.serviceWorker.ready;
|
||
let sub = await reg.pushManager.getSubscription();
|
||
// A subscription made for another panel key can't be reused.
|
||
if (sub && sub.options?.applicationServerKey && bytesToB64url(sub.options.applicationServerKey) !== d.publicKey) {
|
||
await sub.unsubscribe();
|
||
sub = null;
|
||
}
|
||
if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64urlToBytes(d.publicKey) });
|
||
await act("/api/admin/notifications/subscribe", "POST", { ...sub.toJSON(), userAgent: navigator.userAgent, timezone: browserTimezone() });
|
||
} catch (err) {
|
||
toast(`Couldn't turn notifications on: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async function disableNotifications(all) {
|
||
let endpoint = "";
|
||
try {
|
||
const sub = await pushSubscription();
|
||
if (sub) {
|
||
endpoint = sub.endpoint;
|
||
await sub.unsubscribe();
|
||
}
|
||
} catch { /* the server copy is removed below either way */ }
|
||
if (all) return act("/api/admin/notifications/unsubscribe", "POST", { all: true });
|
||
if (!endpoint) { notifications(); return; }
|
||
return act("/api/admin/notifications/unsubscribe", "POST", { endpoint });
|
||
}
|
||
|
||
/* ---------- 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${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${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>
|
||
|
||
${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${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>') : ""}
|
||
</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>
|
||
${verifySection(d.verify, p.steamid, name)}
|
||
${ptDrawer(d.playtime, d.online)}
|
||
${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>`);
|
||
}
|
||
|
||
// 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 || 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>
|
||
<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 playtime = () => ptPage(state.me, () => state.page === "playtime");
|
||
|
||
const RENDER = { overview, players, bans, comms, playtime, staff, verify, settings, notifications };
|
||
|
||
function renderNav() {
|
||
const c = state.counts;
|
||
const count = { players: c.players, bans: c.bans, comms: c.comms, staff: c.staff };
|
||
$("#nav-links").innerHTML = PAGES.filter(pageAllowed).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("");
|
||
}
|
||
|
||
// The mobile sidebar covers the Menu button, so it closes from its own Close button, the
|
||
// backdrop, Escape, or picking a page.
|
||
function setNav(open) {
|
||
$("#nav").classList.toggle("open", open);
|
||
$("#menu-btn").setAttribute("aria-expanded", String(open));
|
||
}
|
||
|
||
function route() {
|
||
const page = location.hash.slice(1);
|
||
const def = PAGES.find((p) => p.id === page);
|
||
state.page = def && pageAllowed(def) ? page : "overview";
|
||
renderNav();
|
||
setNav(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(); }
|
||
if (e.target.matches("[data-notif-time]")) saveNotifPrefs();
|
||
});
|
||
|
||
document.addEventListener("click", (e) => {
|
||
const t = e.target;
|
||
|
||
if (t.closest("#menu-btn")) {
|
||
setNav(!$("#nav").classList.contains("open"));
|
||
return;
|
||
}
|
||
if (t.closest("#nav-close, #nav-backdrop")) {
|
||
setNav(false);
|
||
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.matches("[data-ntype], [data-notif]")) {
|
||
sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true"));
|
||
saveNotifPrefs();
|
||
return;
|
||
}
|
||
if (sw && !sw.disabled) {
|
||
sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true"));
|
||
sw.classList.add("dirty");
|
||
markDirty();
|
||
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; }
|
||
|
||
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("", "", "", d.group || "");
|
||
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 "override": return overrideDialog(d.sid || "", d.name || "");
|
||
case "remove-override":
|
||
confirmDialog.reason = false;
|
||
return confirmDialog("Remove override", `Remove <b>${esc(d.name || d.sid)}</b>'s override? They stay verified only if they're in the Steam group.`, "Remove",
|
||
() => act(`/api/admin/verify/overrides/${encodeURIComponent(d.sid)}`, "DELETE"));
|
||
case "gv-toggle": {
|
||
const on = d.on === "1";
|
||
confirmDialog.reason = false;
|
||
return confirmDialog(on ? "Turn verification on" : "Turn verification off", on
|
||
? "Turn GroupVerify on? Players in the Steam group and with overrides become verified."
|
||
: "Turn GroupVerify off? Nobody is verified while it's off, overrides included, and verified players lose their perks.",
|
||
on ? "Turn on" : "Turn off", () => act("/api/admin/verify/settings", "POST", { enabled: on }));
|
||
}
|
||
case "notif-on": return enableNotifications();
|
||
case "notif-off": return disableNotifications(false);
|
||
case "notif-off-all":
|
||
confirmDialog.reason = false;
|
||
return confirmDialog("Turn off everywhere", "Turn notifications off on all your devices? Turn them on again on each device you want them on.", "Turn off", () => disableNotifications(true));
|
||
case "notif-test": return act("/api/admin/notifications/test", "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 === "gv-form") {
|
||
// Only what changed, so saving the tag doesn't resend the group (and the other way round).
|
||
const body = {};
|
||
if (f.group.value.trim() !== f.group.dataset.was) body.groupId = f.group.value.trim();
|
||
if (f.clantag.value.trim() !== f.clantag.dataset.was) body.clanTag = f.clantag.value.trim();
|
||
if (!Object.keys(body).length) { toast("Nothing changed."); return; }
|
||
await act("/api/admin/verify/settings", "POST", body);
|
||
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 });
|
||
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 "override": {
|
||
const days = form.querySelector('[data-chips="days"] [aria-pressed="true"]');
|
||
await act("/api/admin/verify/overrides", "POST", { steamid: f.steamid.value.trim(), days: Number(days?.dataset.value ?? 30) });
|
||
break;
|
||
}
|
||
case "confirm": await confirmDialog.run?.(f); break;
|
||
}
|
||
});
|
||
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && $("#nav").classList.contains("open")) { setNav(false); return; }
|
||
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 = `${avatar(state.me.steamid, state.me.name)}
|
||
<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();
|
||
})();
|