1208 lines
66 KiB
JavaScript
1208 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" },
|
||
{ 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> ${p.verified ? VERIFIED_TAG : ""} ${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, cls = "") => `<div class="setting${cls}">
|
||
<div><b>${label}</b>${help ? `<p>${help}</p>` : ""}</div>
|
||
<div class="control">${control}</div>
|
||
</div>`;
|
||
// A row whose control is one switch: on phones the switch goes before the text.
|
||
const toggle = (label, help, sw) => row(label, help, sw, " toggle");
|
||
|
||
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>
|
||
${toggle("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) => toggle(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>' : ""}${d.verify?.verified ? ` ${VERIFIED_TAG}` : ""}</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)}
|
||
${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 RENDER = { overview, players, bans, comms, 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();
|
||
})();
|