simpleadmin-web 0.1.0: web panel for CS2-SimpleAdmin
This commit is contained in:
commit
4f38daf0e6
31 changed files with 6870 additions and 0 deletions
835
internal/web/assets/admin.js
Normal file
835
internal/web/assets/admin.js
Normal file
|
|
@ -0,0 +1,835 @@
|
|||
// Staff panel: hash-routed pages over the /api/admin endpoints. Buttons only appear for what the
|
||||
// signed-in rank may do, but the server checks every action again.
|
||||
|
||||
const PAGES = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "players", label: "Players" },
|
||||
{ id: "bans", label: "Bans" },
|
||||
{ id: "comms", label: "Gags & mutes" },
|
||||
{ id: "staff", label: "Staff & ranks" },
|
||||
{ id: "settings", label: "Server settings" },
|
||||
];
|
||||
|
||||
const state = {
|
||||
page: "overview",
|
||||
banFilter: "active",
|
||||
commFilter: "active",
|
||||
playerFilter: "online",
|
||||
query: "",
|
||||
num: 1,
|
||||
group: null,
|
||||
me: null,
|
||||
counts: {},
|
||||
};
|
||||
|
||||
const can = (flag) => !!state.me && (state.me.perms.includes("@css/root") || state.me.perms.includes(flag));
|
||||
|
||||
/* ---------- Overview ---------- */
|
||||
|
||||
const ACTIVITY_VERB = {
|
||||
ban: "banned", warn: "warned", unban: "unbanned",
|
||||
comm: { GAG: "gagged", MUTE: "muted", SILENCE: "silenced" },
|
||||
unmute: { GAG: "lifted the gag on", MUTE: "lifted the mute on", SILENCE: "lifted the silence on" },
|
||||
};
|
||||
const ACTIVITY_COLOR = { ban: "var(--accent)", comm: "var(--amber)", warn: "var(--faint)", unban: "var(--green)", unmute: "var(--green)" };
|
||||
|
||||
function activityLine(a) {
|
||||
const verb = typeof ACTIVITY_VERB[a.kind] === "object" ? ACTIVITY_VERB[a.kind][a.type] : ACTIVITY_VERB[a.kind];
|
||||
const len = (a.kind === "ban" || a.kind === "comm") ? (a.duration === 0 ? " permanently" : ` for ${fmtDuration(a.duration)}`) : "";
|
||||
return `<li style="--c:${ACTIVITY_COLOR[a.kind]}"><div><p><b>${esc(a.admin)}</b> ${verb} <b>${esc(a.name || a.steamid)}</b>${len}</p>
|
||||
<small>${fmtAgo(a.at)}${a.reason ? `, ${esc(a.reason)}` : ""}</small></div></li>`;
|
||||
}
|
||||
|
||||
function liveHeader(s) {
|
||||
if (!s.online) {
|
||||
return `<div class="live"><div><p class="live-host">The panel can't reach the game server, so live status and in-game actions are off. Bans and blocks still save to the database.</p><div class="live-map">Offline</div></div></div>`;
|
||||
}
|
||||
return `<div class="live">
|
||||
<div>
|
||||
<p class="live-host"><span class="dot"></span>${esc(s.hostname)}${s.warmup ? ", warmup" : ""}</p>
|
||||
<div class="live-map">${esc(s.map)}</div>
|
||||
</div>
|
||||
<div class="live-stats">
|
||||
<div><b>${s.count}<span class="faint">/${s.maxPlayers}</span></b><small>Players</small></div>
|
||||
<div><b><span class="team t score">${s.scoreT}</span> <span class="faint">:</span> <span class="team ct score">${s.scoreCt}</span></b><small>T vs CT</small></div>
|
||||
<div><b>${s.staffOnline}</b><small>Staff online</small></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function tagsFor(p) {
|
||||
return (p.tags || []).map((t) => {
|
||||
if (t.startsWith("WARN:")) {
|
||||
const n = t.slice(5);
|
||||
return `<span class="tag" title="Active warnings">${esc(n)} warn${n === "1" ? "" : "s"}</span>`;
|
||||
}
|
||||
return `<span class="tag amber" title="${COMM_HELP[t]}">${COMM_LABEL[t]}</span>`;
|
||||
}).join(" ");
|
||||
}
|
||||
|
||||
function onlineTable(players) {
|
||||
const list = players.filter((p) => !p.bot).sort((a, b) => (a.team === b.team ? b.kills - a.kills : b.team - a.team));
|
||||
if (!list.length) return '<div class="empty">Nobody is on the server.</div>';
|
||||
const teamName = { 2: "T", 3: "CT" };
|
||||
return `<table>
|
||||
<thead><tr><th>Player</th><th class="hide-sm">Rank</th><th class="num">K / D</th><th class="num hide-sm">Ping</th><th><span class="sr">Actions</span></th></tr></thead>
|
||||
<tbody>
|
||||
${list.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}">
|
||||
<td><div class="who"><span class="team ${p.team === 3 ? "ct" : p.team === 2 ? "t" : ""}">${teamName[p.team] || "SPEC"}</span><div><b>${esc(p.name)}</b> ${tagsFor(p)}</div></div></td>
|
||||
<td class="hide-sm">${rankTag(p.rank)}</td>
|
||||
<td class="num">${p.kills} / ${p.deaths}</td>
|
||||
<td class="num hide-sm ${p.ping > 80 ? "" : "muted"}">${p.ping}</td>
|
||||
<td><div class="row-actions">${p.canTarget ? `
|
||||
${can("@css/chat") ? `<button class="btn sm ghost" type="button" data-act="comm" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}">Gag</button>` : ""}
|
||||
${can("@css/kick") ? `<button class="btn sm ghost" type="button" data-act="kick" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}">Kick</button>` : ""}
|
||||
${can("@css/ban") ? `<button class="btn sm danger" type="button" data-act="ban" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}">Ban</button>` : ""}` : ""}
|
||||
</div></td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
async function overview() {
|
||||
const v = $("#view");
|
||||
let d;
|
||||
try { d = await api("/api/admin/overview"); } catch (err) { v.innerHTML = errorBox(err); return; }
|
||||
if (state.page !== "overview") return;
|
||||
state.counts = { bans: d.activeBans, comms: d.activeComms, staff: d.staffCount, players: d.server.online ? `${d.server.count}/${d.server.maxPlayers}` : "" };
|
||||
renderNav();
|
||||
setServerPick(d.server);
|
||||
const s = d.server;
|
||||
v.innerHTML = `
|
||||
${liveHeader(s)}
|
||||
<div class="grid-2">
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>On the server</h2>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
${can("@css/changemap") ? '<button class="btn sm" type="button" data-act="map">Change map</button>' : ""}
|
||||
${can("@css/chat") ? '<button class="btn sm" type="button" data-act="say">Message all</button>' : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">${s.online ? onlineTable(s.players) : '<div class="empty">Offline.</div>'}</div>
|
||||
</section>
|
||||
|
||||
<div class="stack">
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2>Recent actions</h2><a class="muted" href="#bans" style="font-size:14px">All bans</a></div>
|
||||
${d.activity.length ? `<ul class="feed">${d.activity.map(activityLine).join("")}</ul>` : '<div class="empty">Nothing yet.</div>'}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2>Ending soon</h2><p>${d.activeBans} bans, ${d.activeComms} blocks active</p></div>
|
||||
${d.expiring.length ? `<ul class="expiring">${d.expiring.map((p) => `<li><span><a href="#" data-player="${esc(p.steamid)}"><b>${esc(p.name || p.steamid)}</b></a> <span class="muted">${p.kind === "ban" ? "ban" : COMM_LABEL[p.type].toLowerCase()}</span></span><span class="muted">${fmtLeft(served(p).left)}</span></li>`).join("")}</ul>` : '<div class="empty">No timed bans or blocks.</div>'}
|
||||
</section>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function setServerPick(s) {
|
||||
const el = $("#server-pick");
|
||||
el.innerHTML = s.online
|
||||
? `<b><span class="dot"></span>${esc(s.hostname)}</b><small>${esc(s.map)}, ${s.count}/${s.maxPlayers} players</small>`
|
||||
: `<b>Server offline</b><small>Live status unavailable</small>`;
|
||||
}
|
||||
|
||||
/* ---------- Players ---------- */
|
||||
|
||||
function players() {
|
||||
$("#view").innerHTML = `
|
||||
<div class="page-head">
|
||||
<div><h1>Players</h1><p class="sub">Everyone SimpleAdmin has seen join, with their rank and record. Search by name, SteamID64${can("@css/showip") ? " or IP address" : ""}.</p></div>
|
||||
</div>
|
||||
<section class="panel">
|
||||
<div class="toolbar">
|
||||
${searchBox("Search players", state.query)}
|
||||
${segmented("playerFilter", state.playerFilter, [["online", "Online"], ["ranked", "Ranked"], ["all", "All"]])}
|
||||
</div>
|
||||
<div class="table-wrap" id="results">${loading()}</div>
|
||||
</section>`;
|
||||
loadResults();
|
||||
}
|
||||
|
||||
function playersTable(list) {
|
||||
if (!list.items.length) {
|
||||
if (state.query) return `<div class="empty">No players match "${esc(state.query)}". Try a SteamID64 or part of a name.</div>`;
|
||||
return `<div class="empty">${state.playerFilter === "online" ? "Nobody is on the server." : "No players yet."}</div>`;
|
||||
}
|
||||
return `<table>
|
||||
<thead><tr><th>Player</th><th>Rank</th><th class="hide-sm">Record</th><th class="hide-sm">Last seen</th></tr></thead>
|
||||
<tbody>
|
||||
${list.items.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}">
|
||||
<td><div class="who"><span class="avatar">${initial(p.name)}</span><div><b>${esc(p.name || p.steamid)}</b><span class="sid">${esc(p.steamid)}</span></div></div></td>
|
||||
<td>${rankTag(p.rank)}</td>
|
||||
<td class="hide-sm">
|
||||
${p.banned ? '<span class="tag red">Banned</span>' : ""}
|
||||
${p.bans ? `<span class="tag">${p.bans} ban${p.bans > 1 ? "s" : ""}</span>` : ""}
|
||||
${p.comms ? `<span class="tag">${p.comms} block${p.comms > 1 ? "s" : ""}</span>` : ""}
|
||||
${p.warns ? `<span class="tag">${p.warns} warn${p.warns > 1 ? "s" : ""}</span>` : ""}
|
||||
${!p.bans && !p.comms && !p.warns ? '<span class="faint">Clean</span>' : ""}
|
||||
</td>
|
||||
<td class="hide-sm">${p.online ? '<span class="tag green">Online</span>' : p.lastSeen ? `<span class="muted">${fmtAgo(p.lastSeen)}</span>` : '<span class="faint">Never joined</span>'}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>${pager(list)}`;
|
||||
}
|
||||
|
||||
/* ---------- Bans and comms ---------- */
|
||||
|
||||
function punishTable(list, kind) {
|
||||
if (!list.items.length) {
|
||||
return `<div class="empty">${state.query ? `Nothing matches "${esc(state.query)}".` : kind === "ban" ? "No bans in this view." : "No gags or mutes in this view."}</div>`;
|
||||
}
|
||||
const canLift = kind === "ban" ? can("@css/unban") : can("@css/chat");
|
||||
const canAdd = kind === "ban" ? can("@css/ban") : can("@css/chat");
|
||||
return `<table>
|
||||
<thead><tr>
|
||||
<th>Player</th>
|
||||
${kind === "comm" ? "<th>Type</th>" : ""}
|
||||
<th>Reason</th>
|
||||
<th>Length</th>
|
||||
<th class="hide-sm">Issued</th>
|
||||
<th>Status</th>
|
||||
<th><span class="sr">Actions</span></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${list.items.map((p) => `<tr>
|
||||
<td><div class="who"><div><b>${p.steamid ? `<a href="#" data-player="${esc(p.steamid)}">${esc(p.name || p.steamid)}</a>` : esc(p.name || "Unknown")}</b><span class="sid">${esc(p.steamid)}${p.ip ? `<br>${esc(p.ip)}` : ""}</span></div></div></td>
|
||||
${kind === "comm" ? `<td><span class="tag amber" title="${COMM_HELP[p.type]}">${COMM_LABEL[p.type]}</span></td>` : ""}
|
||||
<td>${esc(p.reason)}${p.liftedBy || p.liftReason ? `<br><small class="muted">Lifted${p.liftedBy ? ` by ${esc(p.liftedBy)}` : ""}${p.liftReason ? `: ${esc(p.liftReason)}` : ""}</small>` : ""}</td>
|
||||
<td>${termCell(p)}</td>
|
||||
<td class="hide-sm"><span>${fmtDate(p.created)}</span><br><small class="muted">by ${esc(p.admin || "Console")}</small></td>
|
||||
<td>${statusTag(p)}</td>
|
||||
<td><div class="row-actions">
|
||||
${p.status === "active" && canLift && p.steamid ? `<button class="btn sm danger" type="button" data-act="lift" data-kind="${kind}" data-id="${p.id}" data-name="${esc(p.name)}" data-type="${esc(p.type || "")}">${kind === "ban" ? "Unban" : "Lift"}</button>` : ""}
|
||||
${p.status !== "active" && canAdd && p.steamid ? `<button class="btn sm ghost" type="button" data-act="${kind === "ban" ? "ban" : "comm"}" data-sid="${esc(p.steamid)}" data-type="${esc(p.type || "")}" data-reason="${esc(p.reason)}">Reapply</button>` : ""}
|
||||
</div></td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>${pager(list)}`;
|
||||
}
|
||||
|
||||
function bans() {
|
||||
$("#view").innerHTML = `
|
||||
<div class="page-head">
|
||||
<div><h1>Bans</h1><p class="sub">The bar under each length shows how much of the ban has been served. Striped bars never expire.</p></div>
|
||||
${can("@css/ban") ? `<button class="btn primary" type="button" data-act="ban">${ICON.plus}Add ban</button>` : ""}
|
||||
</div>
|
||||
<section class="panel">
|
||||
<div class="toolbar">
|
||||
${searchBox(`Search by name, SteamID, ${can("@css/showip") ? "IP, " : ""}reason or admin`, state.query)}
|
||||
${segmented("banFilter", state.banFilter, [["active", "Active"], ["expired", "Expired"], ["lifted", "Lifted"], ["all", "All"]])}
|
||||
</div>
|
||||
<div class="table-wrap" id="results">${loading()}</div>
|
||||
</section>`;
|
||||
loadResults();
|
||||
}
|
||||
|
||||
function comms() {
|
||||
$("#view").innerHTML = `
|
||||
<div class="page-head">
|
||||
<div><h1>Gags & mutes</h1><p class="sub">A gag blocks text chat, a mute blocks voice, and a silence blocks both.</p></div>
|
||||
${can("@css/chat") ? `<button class="btn primary" type="button" data-act="comm">${ICON.plus}Add gag or mute</button>` : ""}
|
||||
</div>
|
||||
<section class="panel">
|
||||
<div class="toolbar">
|
||||
${searchBox("Search by name, SteamID, reason or admin", state.query)}
|
||||
${segmented("commFilter", state.commFilter, [["active", "Active"], ["GAG", "Gags"], ["MUTE", "Mutes"], ["SILENCE", "Silences"], ["expired", "Expired"], ["all", "All"]])}
|
||||
</div>
|
||||
<div class="table-wrap" id="results">${loading()}</div>
|
||||
</section>`;
|
||||
loadResults();
|
||||
}
|
||||
|
||||
async function loadResults() {
|
||||
const box = $("#results");
|
||||
if (!box) return;
|
||||
let path, status, render;
|
||||
if (state.page === "players") {
|
||||
path = "/api/admin/players"; render = playersTable;
|
||||
} else if (state.page === "bans") {
|
||||
path = "/api/admin/bans"; status = state.banFilter; render = (l) => punishTable(l, "ban");
|
||||
} else {
|
||||
path = "/api/admin/comms"; status = state.commFilter; render = (l) => punishTable(l, "comm");
|
||||
}
|
||||
const params = { status, q: state.query, page: state.num, filter: state.page === "players" ? state.playerFilter : undefined };
|
||||
const want = JSON.stringify([state.page, params]);
|
||||
loadResults.want = want;
|
||||
try {
|
||||
const list = await api(path + qs(params));
|
||||
if (loadResults.want !== want) return;
|
||||
box.innerHTML = render(list);
|
||||
} catch (err) {
|
||||
box.innerHTML = errorBox(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Staff & ranks ---------- */
|
||||
|
||||
async function staff() {
|
||||
const v = $("#view");
|
||||
let d;
|
||||
try { d = await api("/api/admin/staff"); } catch (err) { v.innerHTML = errorBox(err); return; }
|
||||
if (state.page !== "staff") return;
|
||||
staff.data = d;
|
||||
const manage = d.canManage;
|
||||
if (!d.groups.some((g) => g.id === state.group)) state.group = d.groups[0]?.id ?? null;
|
||||
const g = d.groups.find((x) => x.id === state.group);
|
||||
|
||||
const ladderItem = (x) => `<li><button type="button" data-group="${x.id}" aria-current="${x.id === state.group}" style="--c:${esc(x.color)}">
|
||||
<i></i><span><b>${esc(x.label)}</b><small>${x.members.length} ${x.members.length === 1 ? "member" : "members"}</small></span><span class="imm">${x.immunity}</span>
|
||||
</button></li>`;
|
||||
const staffGroups = d.groups.filter((x) => !x.supporter);
|
||||
const supporters = d.groups.filter((x) => x.supporter);
|
||||
|
||||
const memberRow = (p, groupName) => `<tr class="clickable" data-player="${esc(p.steamid)}">
|
||||
<td><div class="who"><span class="avatar">${initial(p.name)}</span><div><b>${esc(p.name)}</b><span class="sid">${esc(p.steamid)}</span></div></div></td>
|
||||
<td class="hide-sm">${p.online ? '<span class="tag green">Online</span>' : p.lastSeen ? `<span class="muted">Seen ${fmtAgo(p.lastSeen)}</span>` : '<span class="faint">Never joined</span>'}</td>
|
||||
<td class="muted hide-sm">${p.ends ? `Until ${fmtDate(p.ends)}` : "No expiry"}</td>
|
||||
<td><div class="row-actions">${manage && p.steamid !== d.me ? `<button class="btn sm ghost" type="button" data-act="rank" data-sid="${esc(p.steamid)}" data-name="${esc(p.name)}" data-group="${esc(groupName)}">Change rank</button>` : ""}</div></td>
|
||||
</tr>`;
|
||||
|
||||
const allFlags = [...d.flags];
|
||||
if (g) for (const f of g.flags) if (!allFlags.some((x) => x.flag === f)) allFlags.push({ flag: f, label: f, help: "Custom permission" });
|
||||
const root = g && g.flags.includes("@css/root");
|
||||
|
||||
v.innerHTML = `
|
||||
<div class="page-head">
|
||||
<div><h1>Staff & ranks</h1><p class="sub">Ranks are listed by immunity. Staff can only act on players whose immunity isn't higher than their own.${manage ? "" : " Only Root can change ranks and staff."}</p></div>
|
||||
${manage ? `<div style="display:flex;gap:8px">
|
||||
<button class="btn" type="button" data-act="new-group">${ICON.plus}New rank</button>
|
||||
<button class="btn primary" type="button" data-act="add-staff">${ICON.plus}Add staff</button>
|
||||
</div>` : ""}
|
||||
</div>
|
||||
|
||||
${d.groups.length ? `<div class="ranks-layout">
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2>Ranks</h2><p>Immunity</p></div>
|
||||
<ul class="ladder">
|
||||
${staffGroups.map(ladderItem).join("")}
|
||||
${supporters.length ? `<li class="sep">Supporter ranks</li>${supporters.map(ladderItem).join("")}` : ""}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="stack">
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2><span class="rank" style="--c:${esc(g.color)};font:inherit"><i></i>${esc(g.label)}</span></h2>
|
||||
<span class="sid">${esc(g.name)}</span>
|
||||
</div>
|
||||
<form class="panel-body" id="group-form" style="display:grid;gap:18px" data-id="${g.id}">
|
||||
<div class="form-row">
|
||||
<label class="field"><span>SimpleAdmin name</span><input class="input" name="name" value="${esc(g.name)}" ${manage ? "" : "readonly"} required><small>The display name and colour come from the panel's site config.</small></label>
|
||||
<label class="field"><span>Immunity</span><input class="input" name="immunity" type="number" min="0" max="9999" value="${g.immunity}" ${manage ? "" : "readonly"} required><small>0 to 9999</small></label>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="margin-bottom:10px">Permissions</h3>
|
||||
<div class="flag-grid">
|
||||
${allFlags.map((f) => {
|
||||
const on = g.flags.includes(f.flag);
|
||||
const inherited = root && f.flag !== "@css/root";
|
||||
return `<label class="flag"><input type="checkbox" name="flag" value="${esc(f.flag)}" ${on || inherited ? "checked" : ""} ${inherited || !manage ? "disabled" : ""} ${on && inherited ? 'data-kept="1"' : ""}>
|
||||
<span><b>${esc(f.label)}</b><small>${inherited ? "Included by Root" : esc(f.help)}</small><br><small class="faint">${esc(f.flag)}</small></span></label>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
${manage ? `<div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn danger" type="button" data-act="delete-group" data-id="${g.id}" data-name="${esc(g.label)}">Delete rank</button>
|
||||
<button class="btn primary" type="submit">Save rank</button>
|
||||
</div>` : ""}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2>Members</h2><p>${g.members.length} ${g.members.length === 1 ? "person" : "people"}</p></div>
|
||||
<div class="table-wrap">
|
||||
${g.members.length ? `<table><tbody>${g.members.map((p) => memberRow(p, g.name)).join("")}</tbody></table>`
|
||||
: `<div class="empty">Nobody has this rank yet.${manage ? " Use Add staff to give it to someone." : ""}</div>`}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
${d.unranked.length ? `<section class="panel">
|
||||
<div class="panel-head"><h2>Admins without a rank</h2><p>Permissions set directly in SimpleAdmin</p></div>
|
||||
<div class="table-wrap"><table><tbody>${d.unranked.map((p) => memberRow(p, "")).join("")}</tbody></table></div>
|
||||
</section>` : ""}
|
||||
</div>
|
||||
</div>` : `<div class="empty">SimpleAdmin has no ranks for this server yet.${manage ? " Use New rank to create one." : ""}</div>`}`;
|
||||
}
|
||||
|
||||
/* ---------- Settings ---------- */
|
||||
|
||||
async function settings() {
|
||||
const v = $("#view");
|
||||
let d;
|
||||
try { d = await api("/api/admin/settings"); } catch (err) { v.innerHTML = errorBox(err); return; }
|
||||
if (state.page !== "settings") return;
|
||||
const sa = d.simpleadmin;
|
||||
|
||||
const cvarControl = (c) => {
|
||||
if (c.type === "bool") return `<button class="switch" type="button" role="switch" aria-checked="${c.value}" aria-label="${esc(c.label)}" data-cvar="${esc(c.key)}"></button>`;
|
||||
if (c.type === "int") return `<input class="input" type="number" value="${esc(c.value)}" data-cvar="${esc(c.key)}">`;
|
||||
return `<input class="input" value="${esc(c.value)}" data-cvar="${esc(c.key)}" ${c.key === "sv_password" ? 'placeholder="No password"' : ""}>`;
|
||||
};
|
||||
const saControl = (s) => {
|
||||
const val = sa.values[s.key];
|
||||
const dis = d.canRoot ? "" : "disabled";
|
||||
if (s.type === "bool") return `<button class="switch" type="button" role="switch" aria-checked="${val}" aria-label="${esc(s.label)}" data-sa="${esc(s.key)}" ${dis}></button>`;
|
||||
if (s.type === "select") return `<select class="input" data-sa="${esc(s.key)}" ${dis}>${s.options.map((o) => `<option value="${o.value}" ${o.value === val ? "selected" : ""}>${esc(o.label)}</option>`).join("")}</select>`;
|
||||
return `<input class="input" type="number" min="${s.min || 0}" value="${val}" data-sa="${esc(s.key)}" ${dis}>`;
|
||||
};
|
||||
const row = (label, help, key, control) => `<div class="setting">
|
||||
<div><b>${esc(label)}</b>${help ? `<p>${esc(help)}</p>` : ""}${key ? `<code>${esc(key)}</code>` : ""}</div>
|
||||
<div class="control">${control}</div>
|
||||
</div>`;
|
||||
|
||||
v.innerHTML = `
|
||||
<div class="page-head">
|
||||
<div><h1>Server settings</h1><p class="sub">Server changes go live over RCON. SimpleAdmin's settings are saved to its config file.</p></div>
|
||||
</div>
|
||||
<div class="settings">
|
||||
<section class="panel" id="cvars">
|
||||
<div class="panel-head"><h2>Server</h2><p>Live over RCON. Resets when the server restarts.</p></div>
|
||||
${!d.canCvar ? '<div class="empty">Your rank can\'t change server settings.</div>'
|
||||
: !d.server.online ? '<div class="empty">The game server isn\'t answering, so these can\'t be read or changed right now.</div>'
|
||||
: (d.cvars || []).map((c) => row(c.label, c.help, c.key, cvarControl(c))).join("")}
|
||||
</section>
|
||||
|
||||
<section class="panel" id="sa">
|
||||
<div class="panel-head"><h2>Punishments</h2><p>${sa.available ? `${sa.path ? `Saved to ${esc(sa.path)}. ` : ""}Applies after the next server restart.` : "Read-only"}</p></div>
|
||||
${!sa.available ? '<div class="empty">The panel wasn\'t given CS2-SimpleAdmin.json (SAW_SA_CONFIG), so these can\'t be shown or changed here.</div>'
|
||||
: sa.error ? `<div class="empty">${esc(sa.error)}</div>`
|
||||
: sa.settings.map((s) => row(s.label, s.help, s.key, saControl(s))).join("")}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2>Connections</h2></div>
|
||||
${row("Database", "SimpleAdmin's MySQL database.", "", d.database.connected ? '<span class="tag green">Connected</span>' : '<span class="tag red">Not connected</span>')}
|
||||
${row("Game server", d.server.online ? "Live status through the WebPanelBridge plugin." : d.server.error || "", "", d.server.online ? '<span class="tag green">Connected</span>' : '<span class="tag red">Not answering</span>')}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2>Maintenance</h2><p>Each of these asks first.</p></div>
|
||||
${can("@css/root") ? row("Reload admins", "Re-read ranks and staff from the database. Runs css_reloadadmins.", "", '<button class="btn" type="button" data-act="reload-admins">Reload admins</button>') : ""}
|
||||
${can("@css/generic") ? row("Restart the match", "Restarts the game on the current map. Runs css_rr.", "", '<button class="btn" type="button" data-act="restart">Restart match</button>') : ""}
|
||||
${can("@css/changemap") ? row("Change map", "Switch to another map, or a Workshop map with ws:<id>.", "", '<button class="btn" type="button" data-act="map">Change map</button>') : ""}
|
||||
</section>
|
||||
</div>
|
||||
<div class="savebar" id="savebar" hidden>
|
||||
<span>You have unsaved changes.</span>
|
||||
<div style="display:flex;gap:8px"><button class="btn ghost" type="button" data-act="discard">Discard</button><button class="btn primary" type="button" data-act="save-settings">Save changes</button></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function markDirty() { const b = $("#savebar"); if (b) b.hidden = false; }
|
||||
|
||||
async function saveSettings() {
|
||||
const cvars = {};
|
||||
const sa = {};
|
||||
document.querySelectorAll("[data-cvar].dirty").forEach((el) => {
|
||||
const key = el.dataset.cvar;
|
||||
if (el.classList.contains("switch")) cvars[key] = el.getAttribute("aria-checked") === "true";
|
||||
else cvars[key] = el.type === "number" ? Number(el.value) : el.value;
|
||||
});
|
||||
document.querySelectorAll("[data-sa].dirty").forEach((el) => {
|
||||
const key = el.dataset.sa;
|
||||
if (el.classList.contains("switch")) sa[key] = el.getAttribute("aria-checked") === "true";
|
||||
else sa[key] = Number(el.value);
|
||||
});
|
||||
const msgs = [];
|
||||
try {
|
||||
if (Object.keys(cvars).length) msgs.push((await api("/api/admin/settings/cvars", { method: "POST", body: cvars })).message);
|
||||
if (Object.keys(sa).length) msgs.push((await api("/api/admin/settings/simpleadmin", { method: "POST", body: sa })).message);
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
return;
|
||||
}
|
||||
toast(msgs.join(" ") || "Nothing to save.");
|
||||
settings();
|
||||
}
|
||||
|
||||
/* ---------- Player drawer ---------- */
|
||||
|
||||
async function openPlayer(sid) {
|
||||
$("#drawer-root").innerHTML = `<div class="scrim" data-close></div><aside class="drawer" role="dialog" aria-modal="true">${loading()}</aside>`;
|
||||
let d;
|
||||
try { d = await api(`/api/admin/players/${encodeURIComponent(sid)}`); } catch (err) {
|
||||
$(".drawer").innerHTML = errorBox(err);
|
||||
return;
|
||||
}
|
||||
const p = d.player;
|
||||
const name = p.name || p.steamid;
|
||||
const history = [
|
||||
...d.bans.map((b) => ({ ...b, label: "Ban" })),
|
||||
...d.comms.map((c) => ({ ...c, label: COMM_LABEL[c.type] })),
|
||||
...d.warns.map((w) => ({ ...w, label: "Warning" })),
|
||||
].sort((a, b) => new Date(b.created) - new Date(a.created));
|
||||
const t = d.canTarget;
|
||||
const btn = (act, label, perm, extra = "", cls = "") => can(perm)
|
||||
? `<button class="btn ${cls}" type="button" data-act="${act}" data-sid="${esc(p.steamid)}" data-name="${esc(name)}" ${extra} ${t ? "" : "disabled"}>${label}</button>` : "";
|
||||
const manage = can("@css/root") && p.steamid !== state.me.steamid;
|
||||
|
||||
$("#drawer-root").innerHTML = `
|
||||
<div class="scrim" data-close></div>
|
||||
<aside class="drawer" role="dialog" aria-modal="true" aria-label="${esc(name)}">
|
||||
<div class="drawer-head">
|
||||
<span class="avatar lg">${initial(name)}</span>
|
||||
<div><h2>${esc(name)}</h2><div>${rankTag(d.rank)} ${d.online ? '<span class="tag green" style="margin-left:6px">Online</span>' : ""}</div></div>
|
||||
<button class="close" type="button" data-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<section>
|
||||
${t ? "" : `<p class="muted" style="margin:0 0 12px">${p.steamid === state.me.steamid ? "This is you." : "This player's immunity is higher than yours, so you can't act on them."}</p>`}
|
||||
<div class="action-grid">
|
||||
${btn("ban", "Ban", "@css/ban", "", "danger")}
|
||||
${btn("comm", "Gag", "@css/chat", 'data-type="GAG"')}
|
||||
${btn("comm", "Mute", "@css/chat", 'data-type="MUTE"')}
|
||||
${btn("comm", "Silence", "@css/chat", 'data-type="SILENCE"')}
|
||||
${d.online ? btn("warn", "Warn", "@css/kick") : ""}
|
||||
${d.online ? btn("kick", "Kick", "@css/kick") : ""}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Details</h3>
|
||||
<dl class="kv">
|
||||
<dt>SteamID64</dt><dd>${esc(p.steamid)}</dd>
|
||||
<dt>Profile</dt><dd><a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">Steam profile</a></dd>
|
||||
${p.ips ? `<dt>Known IPs</dt><dd>${p.ips.map(esc).join(", ") || '<span class="faint">None</span>'}</dd>` : ""}
|
||||
<dt>Known names</dt><dd>${(p.names || []).map(esc).join(", ") || '<span class="faint">None</span>'}</dd>
|
||||
<dt>Last seen</dt><dd>${d.online ? "On the server now" : p.lastSeen ? fmtAgo(p.lastSeen) : "Never joined"}</dd>
|
||||
${d.immunity ? `<dt>Immunity</dt><dd>${d.immunity}</dd>` : ""}
|
||||
${d.adminEnds ? `<dt>Rank expires</dt><dd>${fmtDate(d.adminEnds)}</dd>` : ""}
|
||||
</dl>
|
||||
</section>
|
||||
${manage ? `<section>
|
||||
<h3>Rank</h3>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn" type="button" data-act="rank" data-sid="${esc(p.steamid)}" data-name="${esc(name)}" data-group="${esc(d.rank?.name || "")}" ${t ? "" : "disabled"}>${d.rank ? "Change rank" : "Give a rank"}</button>
|
||||
</div>
|
||||
</section>` : ""}
|
||||
<section>
|
||||
<h3>History</h3>
|
||||
${history.length ? `<ul class="history">${history.map((h) => `<li>
|
||||
<div class="top"><span><b>${h.label}</b> <span class="muted">by ${esc(h.admin || "Console")}, ${fmtAgo(h.created)}</span></span>${statusTag(h)}</div>
|
||||
<div>${esc(h.reason)}</div>
|
||||
${h.kind === "warn" ? "" : termCell(h)}
|
||||
</li>`).join("")}</ul>` : '<p class="muted" style="margin:0">No bans, gags, mutes or warnings on record.</p>'}
|
||||
</section>
|
||||
</aside>`;
|
||||
$(".drawer .close").focus();
|
||||
}
|
||||
|
||||
/* ---------- Dialogs ---------- */
|
||||
|
||||
const DURATIONS = [[30, "30 min"], [60, "1 hour"], [1440, "1 day"], [10080, "1 week"], [43200, "30 days"], [0, "Permanent"]];
|
||||
const REASONS = {
|
||||
ban: ["Cheating", "Ban evasion", "Toxicity", "Griefing", "Exploiting"],
|
||||
comm: ["Spam", "Toxicity", "Mic spam", "Soundboard", "Advertising"],
|
||||
warn: ["Toxicity", "Spam", "Griefing", "Ignoring staff"],
|
||||
};
|
||||
|
||||
function punishDialog(kind, { sid = "", name = "", type = "GAG", reason = "" } = {}) {
|
||||
const title = { ban: "Ban player", comm: "Gag or mute player", warn: "Warn player" }[kind];
|
||||
const submit = { ban: "Ban player", comm: "Apply block", warn: "Warn player" }[kind];
|
||||
const perm = kind === "ban" ? "@css/permban" : "@css/permmute";
|
||||
openModal(`
|
||||
<form method="dialog" data-submit="penalty" data-kind="${kind}">
|
||||
<div class="modal-head"><h2>${title}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
|
||||
<div class="modal-body">
|
||||
<label class="field"><span>Player</span>
|
||||
<input class="input" name="steamid" required pattern="7656119[0-9]{10}" value="${esc(sid)}" placeholder="SteamID64, like 76561198012345678" ${sid ? "readonly" : ""}>
|
||||
<small>${name ? esc(name) : kind === "warn" ? "Warnings only work on players who are on the server." : "Works for players who are offline too; it applies when they next join."}</small>
|
||||
</label>
|
||||
${kind === "comm" ? `<div class="field"><span>Block</span>${segmented("commType", type || "GAG", [["GAG", "Text (gag)"], ["MUTE", "Voice (mute)"], ["SILENCE", "Both (silence)"]])}</div>` : ""}
|
||||
<div class="field"><span>${kind === "warn" ? "Warning lasts" : "Length"}</span>
|
||||
<div class="chips" data-chips="dur">${DURATIONS.map(([v, l], i) => `<button type="button" class="chip-btn" data-value="${v}" aria-pressed="${i === (kind === "ban" ? 2 : kind === "warn" ? 4 : 1)}">${l}</button>`).join("")}</div>
|
||||
${kind === "warn" ? "" : `<small>${can(perm) ? "Your rank can use any length." : "Your rank's limit is set in SimpleAdmin's settings; permanent isn't allowed."}</small>`}
|
||||
</div>
|
||||
<div class="field"><span>Reason</span>
|
||||
<div class="chips" data-chips="reason">${REASONS[kind].map((r) => `<button type="button" class="chip-btn" data-value="${esc(r)}" aria-pressed="false">${esc(r)}</button>`).join("")}</div>
|
||||
<textarea class="input" name="reason" id="reason" required maxlength="200" placeholder="Shown to the player and in the log">${esc(reason)}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn ghost" type="button" data-modal-close>Cancel</button>
|
||||
<button class="btn primary" type="submit">${submit}</button>
|
||||
</div>
|
||||
</form>`);
|
||||
}
|
||||
|
||||
async function rankDialog(sid, name, current) {
|
||||
if (!staff.data) {
|
||||
try { staff.data = await api("/api/admin/staff"); } catch (err) { toast(err.message); return; }
|
||||
}
|
||||
const groups = staff.data.groups;
|
||||
const opts = (groups || []).map((g) => `<option value="${esc(g.name)}" ${g.name === current ? "selected" : ""}>${esc(g.label)} (${g.immunity})</option>`).join("");
|
||||
openModal(`
|
||||
<form method="dialog" data-submit="rank" data-sid="${esc(sid)}" data-had="${esc(current)}">
|
||||
<div class="modal-head"><h2>${current ? "Change rank" : "Add staff"}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
|
||||
<div class="modal-body">
|
||||
<label class="field"><span>Player</span>
|
||||
<input class="input" name="steamid" required pattern="7656119[0-9]{10}" value="${esc(sid)}" placeholder="SteamID64, like 76561198012345678" ${sid ? "readonly" : ""}>
|
||||
${name ? `<small>${esc(name)}</small>` : ""}
|
||||
</label>
|
||||
<label class="field"><span>Rank</span><select class="input" name="group">${current ? '<option value="">No rank (remove from staff)</option>' : ""}${opts}</select></label>
|
||||
<label class="field"><span>Expires</span><select class="input" name="days"><option value="0">Never</option><option value="7">In 7 days</option><option value="30">In 30 days</option><option value="90">In 90 days</option></select><small>Trial ranks usually expire.</small></label>
|
||||
</div>
|
||||
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">Save</button></div>
|
||||
</form>`);
|
||||
}
|
||||
|
||||
function newGroupDialog() {
|
||||
openModal(`
|
||||
<form method="dialog" data-submit="new-group">
|
||||
<div class="modal-head"><h2>New rank</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
|
||||
<div class="modal-body">
|
||||
<label class="field"><span>SimpleAdmin name</span><input class="input" name="name" required pattern="#[A-Za-z0-9_./\\-]{1,63}" placeholder="#rank/mod"><small>Starts with #. Add a display name and colour for it in the site config.</small></label>
|
||||
<label class="field"><span>Immunity</span><input class="input" name="immunity" type="number" min="0" max="${state.me.immunity}" value="0" required><small>Up to your own immunity, ${state.me.immunity}.</small></label>
|
||||
<p class="muted" style="margin:0">It starts with Generic admin only. Pick its other permissions after creating it.</p>
|
||||
</div>
|
||||
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">Create rank</button></div>
|
||||
</form>`);
|
||||
}
|
||||
|
||||
function textDialog(kind) {
|
||||
const cfg = {
|
||||
map: { title: "Change map", label: "Map", placeholder: "de_mirage, or ws:<workshop id>", submit: "Change map", pattern: "(ws:)?[A-Za-z0-9_\\-]{1,64}" },
|
||||
say: { title: "Message everyone", label: "Message", placeholder: "Shown in chat to everyone on the server", submit: "Send", pattern: ".{1,200}" },
|
||||
kick: { title: "Kick player", label: "Reason", placeholder: "Shown to the player", submit: "Kick", pattern: ".{0,200}" },
|
||||
}[kind];
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function inputDialog(kind, data = {}) {
|
||||
const c = textDialog(kind);
|
||||
openModal(`
|
||||
<form method="dialog" data-submit="${kind}" data-sid="${esc(data.sid || "")}">
|
||||
<div class="modal-head"><h2>${c.title}${data.name ? `: ${esc(data.name)}` : ""}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
|
||||
<div class="modal-body"><label class="field"><span>${c.label}</span><input class="input" name="value" pattern="${c.pattern}" placeholder="${esc(c.placeholder)}" ${kind === "kick" ? "" : "required"} maxlength="200"></label></div>
|
||||
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">${c.submit}</button></div>
|
||||
</form>`);
|
||||
}
|
||||
|
||||
function confirmDialog(title, body, label, run) {
|
||||
confirmDialog.run = run;
|
||||
openModal(`
|
||||
<form method="dialog" data-submit="confirm">
|
||||
<div class="modal-head"><h2>${title}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
|
||||
<div class="modal-body"><p style="margin:0">${body}</p>${confirmDialog.reason ? '<label class="field"><span>Reason</span><input class="input" name="reason" maxlength="200" placeholder="Optional, kept in the log"></label>' : ""}</div>
|
||||
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">${label}</button></div>
|
||||
</form>`);
|
||||
}
|
||||
|
||||
function openModal(html) {
|
||||
const m = $("#modal");
|
||||
m.innerHTML = html;
|
||||
m.showModal();
|
||||
}
|
||||
|
||||
// act runs an API call, shows its message, and refreshes the page and any open drawer.
|
||||
async function act(path, method, body) {
|
||||
try {
|
||||
const res = await api(path, { method, body });
|
||||
toast(res.message || "Done.");
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
return false;
|
||||
}
|
||||
const drawerSid = $(".drawer [data-sid]")?.dataset.sid;
|
||||
route();
|
||||
if (drawerSid) openPlayer(drawerSid);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---------- Routing & events ---------- */
|
||||
|
||||
const RENDER = { overview, players, bans, comms, staff, settings };
|
||||
|
||||
function renderNav() {
|
||||
const c = state.counts;
|
||||
const count = { players: c.players, bans: c.bans, comms: c.comms, staff: c.staff };
|
||||
$("#nav-links").innerHTML = PAGES.map((p) => `<li><a class="link" href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}${count[p.id] !== undefined && count[p.id] !== "" ? `<span class="count">${esc(count[p.id])}</span>` : ""}</a></li>`).join("");
|
||||
}
|
||||
|
||||
function route() {
|
||||
const page = location.hash.slice(1);
|
||||
state.page = RENDER[page] ? page : "overview";
|
||||
renderNav();
|
||||
$("#nav").classList.remove("open");
|
||||
$("#menu-btn").setAttribute("aria-expanded", "false");
|
||||
if (!$("#view").innerHTML) $("#view").innerHTML = loading();
|
||||
RENDER[state.page]();
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", () => { state.query = ""; state.num = 1; $("#view").innerHTML = loading(); route(); window.scrollTo(0, 0); });
|
||||
|
||||
const search = debounce(() => loadResults(), 250);
|
||||
|
||||
document.addEventListener("input", (e) => {
|
||||
const t = e.target;
|
||||
if (t.id === "q") { state.query = t.value; state.num = 1; search(); return; }
|
||||
if (t.matches("[data-cvar], [data-sa]")) { t.classList.add("dirty"); markDirty(); }
|
||||
});
|
||||
|
||||
document.addEventListener("change", (e) => {
|
||||
if (e.target.matches("select[data-sa]")) { e.target.classList.add("dirty"); markDirty(); }
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const t = e.target;
|
||||
|
||||
if (t.closest("#menu-btn")) {
|
||||
const open = $("#nav").classList.toggle("open");
|
||||
$("#menu-btn").setAttribute("aria-expanded", String(open));
|
||||
return;
|
||||
}
|
||||
if (t.closest("#signout")) {
|
||||
api("/auth/logout", { method: "POST" }).finally(() => { location.href = "/"; });
|
||||
return;
|
||||
}
|
||||
|
||||
const seg = t.closest("[data-seg] button");
|
||||
if (seg) {
|
||||
const group = seg.parentElement;
|
||||
group.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === seg)));
|
||||
const key = group.dataset.seg;
|
||||
if (key in state) { state[key] = seg.dataset.value; state.num = 1; loadResults(); }
|
||||
return;
|
||||
}
|
||||
|
||||
const pg = t.closest("[data-page]");
|
||||
if (pg && !pg.disabled) { state.num = Number(pg.dataset.page); loadResults(); return; }
|
||||
|
||||
const chip = t.closest("[data-chips] .chip-btn");
|
||||
if (chip) {
|
||||
const box = chip.parentElement;
|
||||
if (box.dataset.chips === "reason") $("#reason").value = chip.dataset.value;
|
||||
box.querySelectorAll(".chip-btn").forEach((b) => b.setAttribute("aria-pressed", String(b === chip)));
|
||||
return;
|
||||
}
|
||||
|
||||
const sw = t.closest(".switch");
|
||||
if (sw && !sw.disabled) {
|
||||
sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true"));
|
||||
sw.classList.add("dirty");
|
||||
markDirty();
|
||||
return;
|
||||
}
|
||||
|
||||
const grp = t.closest("[data-group]:not([data-act])");
|
||||
if (grp) { state.group = Number(grp.dataset.group); staff(); return; }
|
||||
|
||||
if (t.closest("[data-close]")) { closeDrawer(); return; }
|
||||
if (t.closest("[data-modal-close]")) { $("#modal").close(); return; }
|
||||
|
||||
const a = t.closest("[data-act]");
|
||||
if (a) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (a.disabled) return;
|
||||
const d = a.dataset;
|
||||
switch (d.act) {
|
||||
case "ban": return punishDialog("ban", { sid: d.sid, name: d.name, reason: d.reason });
|
||||
case "comm": return punishDialog("comm", { sid: d.sid, name: d.name, type: d.type, reason: d.reason });
|
||||
case "warn": return punishDialog("warn", { sid: d.sid, name: d.name });
|
||||
case "kick": return inputDialog("kick", { sid: d.sid, name: d.name });
|
||||
case "map": return inputDialog("map");
|
||||
case "say": return inputDialog("say");
|
||||
case "lift": {
|
||||
const what = d.kind === "ban" ? "Unban" : `Lift ${COMM_LABEL[d.type].toLowerCase()}`;
|
||||
confirmDialog.reason = true;
|
||||
return confirmDialog(what, d.kind === "ban"
|
||||
? `Unban <b>${esc(d.name || "this player")}</b>? They can join again straight away. This lifts every active ban on their SteamID.`
|
||||
: `Lift the ${COMM_LABEL[d.type].toLowerCase()} on <b>${esc(d.name || "this player")}</b>?`,
|
||||
d.kind === "ban" ? "Unban" : "Lift", (form) => act("/api/admin/penalties/lift", "POST", { kind: d.kind, id: Number(d.id), reason: form.reason?.value || "" }));
|
||||
}
|
||||
case "rank": return rankDialog(d.sid, d.name, d.group);
|
||||
case "add-staff": return rankDialog("", "", "");
|
||||
case "new-group": return newGroupDialog();
|
||||
case "delete-group":
|
||||
confirmDialog.reason = false;
|
||||
return confirmDialog("Delete rank", `Delete <b>${esc(d.name)}</b>? Its members become regular players.`, "Delete rank",
|
||||
() => act(`/api/admin/groups/${d.id}`, "DELETE"));
|
||||
case "reload-admins":
|
||||
confirmDialog.reason = false;
|
||||
return confirmDialog("Reload admins", "Re-read all ranks and staff from the database?", "Reload admins", () => act("/api/admin/reload-admins", "POST", {}));
|
||||
case "restart":
|
||||
confirmDialog.reason = false;
|
||||
return confirmDialog("Restart the match", "Restart the game on the current map? The current match ends.", "Restart match", () => act("/api/admin/restart", "POST", {}));
|
||||
case "save-settings": return saveSettings();
|
||||
case "discard": return settings();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const row = t.closest("[data-player]");
|
||||
if (row) { e.preventDefault(); openPlayer(row.dataset.player); }
|
||||
});
|
||||
|
||||
document.addEventListener("submit", async (e) => {
|
||||
const form = e.target;
|
||||
e.preventDefault();
|
||||
const kind = form.dataset.submit;
|
||||
const f = form.elements;
|
||||
|
||||
if (form.id === "group-form") {
|
||||
const flags = [...form.querySelectorAll('input[name="flag"]')].filter((i) => i.checked && (!i.disabled || i.dataset.kept)).map((i) => i.value);
|
||||
await act(`/api/admin/groups/${form.dataset.id}`, "PUT", { name: f.name.value.trim(), immunity: Number(f.immunity.value), flags });
|
||||
return;
|
||||
}
|
||||
|
||||
$("#modal").close();
|
||||
switch (kind) {
|
||||
case "penalty": {
|
||||
const dur = form.querySelector('[data-chips="dur"] [aria-pressed="true"]');
|
||||
const type = form.querySelector('[data-seg="commType"] [aria-pressed="true"]')?.dataset.value;
|
||||
await act("/api/admin/penalties", "POST", {
|
||||
kind: form.dataset.kind, type: type || "", steamid: f.steamid.value.trim(),
|
||||
duration: Number(dur?.dataset.value ?? 60), reason: f.reason.value,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "rank": {
|
||||
const sid = f.steamid.value.trim();
|
||||
const group = f.group.value;
|
||||
if (form.dataset.had) await act(`/api/admin/staff/${encodeURIComponent(sid)}`, "PUT", { group, days: Number(f.days.value) });
|
||||
else await act("/api/admin/staff", "POST", { steamid: sid, group, days: Number(f.days.value) });
|
||||
break;
|
||||
}
|
||||
case "new-group":
|
||||
if (await act("/api/admin/groups", "POST", { name: f.name.value.trim(), immunity: Number(f.immunity.value), flags: ["@css/generic"] })) {
|
||||
state.group = null;
|
||||
}
|
||||
break;
|
||||
case "map": await act("/api/admin/map", "POST", { map: f.value.value.trim() }); break;
|
||||
case "say": await act("/api/admin/say", "POST", { message: f.value.value }); break;
|
||||
case "kick": await act("/api/admin/kick", "POST", { steamid: form.dataset.sid, reason: f.value.value }); break;
|
||||
case "confirm": await confirmDialog.run?.(f); break;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && $("#drawer-root").innerHTML && !$("#modal").open) closeDrawer();
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
state.me = await api("/api/me");
|
||||
} catch (err) {
|
||||
$("#view").innerHTML = errorBox(err);
|
||||
return;
|
||||
}
|
||||
if (!state.me.staff) { location.href = "/admin"; return; }
|
||||
const name = state.me.site;
|
||||
const i = name.indexOf(".");
|
||||
document.querySelectorAll(".site-name").forEach((el) => {
|
||||
el.innerHTML = i > 0 ? `${esc(name.slice(0, i))}<span>.</span>${esc(name.slice(i + 1))}` : esc(name);
|
||||
});
|
||||
$("#me").innerHTML = `<span class="avatar">${initial(state.me.name)}</span>
|
||||
<div><b>${esc(state.me.name)}</b><small>${esc(state.me.rank?.label || "Staff")}</small></div>
|
||||
<button class="btn sm ghost" type="button" id="signout">Sign out</button>`;
|
||||
// Fill the sidebar counts once, even when starting on another page.
|
||||
api("/api/admin/overview").then((d) => {
|
||||
state.counts = { bans: d.activeBans, comms: d.activeComms, staff: d.staffCount, players: d.server.online ? `${d.server.count}/${d.server.maxPlayers}` : "" };
|
||||
setServerPick(d.server);
|
||||
renderNav();
|
||||
}).catch(() => {});
|
||||
route();
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue