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
264
internal/web/assets/public.js
Normal file
264
internal/web/assets/public.js
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
// Public, read-only site: the live server, bans, gags/mutes and staff. The API leaves out admin
|
||||
// names and IPs; nothing here can change anything.
|
||||
|
||||
const PAGES = [
|
||||
{ id: "server", label: "Server" },
|
||||
{ id: "bans", label: "Bans" },
|
||||
{ id: "comms", label: "Gags & mutes" },
|
||||
{ id: "staff", label: "Staff" },
|
||||
];
|
||||
|
||||
const state = { page: "server", banFilter: "active", commFilter: "active", query: "", num: 1 };
|
||||
|
||||
function siteName(title) {
|
||||
const i = title.indexOf(".");
|
||||
return i > 0 ? `${esc(title.slice(0, i))}<span>.</span>${esc(title.slice(i + 1))}` : esc(title);
|
||||
}
|
||||
|
||||
/* ---------- Server ---------- */
|
||||
|
||||
async function server() {
|
||||
const v = $("#view");
|
||||
let s;
|
||||
try { s = await api("/api/public/server"); } catch (err) { v.innerHTML = errorBox(err); return; }
|
||||
if (state.page !== "server") return;
|
||||
if (!s.online) {
|
||||
v.innerHTML = `<div class="live"><div><p class="live-host">The server is offline or restarting.</p><div class="live-map">Offline</div></div></div>`;
|
||||
return;
|
||||
}
|
||||
const side = (team, name, score) => {
|
||||
const list = s.players.filter((p) => p.team === team).sort((a, b) => b.kills - a.kills);
|
||||
return `<section class="panel side ${team === 3 ? "ct" : "t"}">
|
||||
<div class="panel-head"><h2>${name}</h2><b class="side-score">${score}</b></div>
|
||||
${list.length ? `<table>
|
||||
<thead><tr><th>Player</th><th class="num">Kills</th><th class="num">Deaths</th><th class="num hide-sm">Ping</th></tr></thead>
|
||||
<tbody>
|
||||
${list.map((p) => `<tr ${p.bot ? "" : `class="clickable" data-player="${esc(p.steamid)}"`}>
|
||||
<td><div class="who"><div><b>${esc(p.name)}</b>${p.bot ? '<span class="sid">Bot</span>' : p.rank ? rankTag(p.rank) : ""}</div></div></td>
|
||||
<td class="num">${p.kills}</td>
|
||||
<td class="num">${p.deaths}</td>
|
||||
<td class="num hide-sm muted">${p.bot ? "" : p.ping}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>` : '<div class="empty">Nobody on this team.</div>'}
|
||||
</section>`;
|
||||
};
|
||||
const spectators = s.players.filter((p) => p.team !== 2 && p.team !== 3 && !p.bot);
|
||||
v.innerHTML = `
|
||||
<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>
|
||||
${s.address ? `<div class="live-join">
|
||||
<a class="btn primary" href="steam://connect/${esc(s.address)}">Join server</a>
|
||||
<small class="muted">or type <b>connect ${esc(s.address)}</b> in the console</small>
|
||||
</div>` : ""}
|
||||
</div>
|
||||
<div class="sides">
|
||||
${side(3, "Counter-Terrorists", s.scoreCt)}
|
||||
${side(2, "Terrorists", s.scoreT)}
|
||||
</div>
|
||||
${spectators.length ? `<p class="muted" style="margin-top:16px">Spectating: ${spectators.map((p) => esc(p.name)).join(", ")}</p>` : ""}`;
|
||||
}
|
||||
|
||||
/* ---------- Bans and comms ---------- */
|
||||
|
||||
function publicTable(rows, kind) {
|
||||
if (!rows.length) {
|
||||
return `<div class="empty">${state.query ? `Nobody matches "${esc(state.query)}". Try the full SteamID64.` : "Nothing in this view."}</div>`;
|
||||
}
|
||||
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>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${rows.map((p) => `<tr ${p.steamid ? `class="clickable" data-player="${esc(p.steamid)}"` : ""}>
|
||||
<td><div class="who"><div><b>${esc(p.name || "Unknown")}</b><span class="sid">${esc(p.steamid)}</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)}</td>
|
||||
<td>${termCell(p)}</td>
|
||||
<td class="hide-sm">${fmtDate(p.created)}</td>
|
||||
<td>${statusTag(p)}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
async function loadResults() {
|
||||
const box = $("#results");
|
||||
if (!box) return;
|
||||
const bans = state.page === "bans";
|
||||
const path = bans ? "/api/public/bans" : "/api/public/comms";
|
||||
const status = bans ? state.banFilter : state.commFilter;
|
||||
const want = `${state.page}|${status}|${state.query}|${state.num}`;
|
||||
loadResults.want = want;
|
||||
try {
|
||||
const list = await api(path + qs({ status, q: state.query, page: state.num }));
|
||||
if (loadResults.want !== want) return;
|
||||
box.innerHTML = publicTable(list.items, bans ? "ban" : "comm") + pager(list);
|
||||
const lead = $("#lead");
|
||||
if (lead && bans) lead.textContent = `${list.active} ${list.active === 1 ? "player is" : "players are"} banned right now. Search for a name or SteamID64 to check a ban and when it ends.`;
|
||||
} catch (err) {
|
||||
box.innerHTML = errorBox(err);
|
||||
}
|
||||
}
|
||||
|
||||
function bans() {
|
||||
$("#view").innerHTML = `
|
||||
<div class="page-head">
|
||||
<div><h1>Bans</h1><p class="sub" id="lead">Search for a name or SteamID64 to check a ban and when it ends.</p></div>
|
||||
</div>
|
||||
<section class="panel">
|
||||
<div class="toolbar">
|
||||
${searchBox("Search by name, SteamID64 or reason", 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. Blocks carry over between maps and reconnects.</p></div>
|
||||
</div>
|
||||
<section class="panel">
|
||||
<div class="toolbar">
|
||||
${searchBox("Search by name, SteamID64 or reason", 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();
|
||||
}
|
||||
|
||||
/* ---------- Staff ---------- */
|
||||
|
||||
async function staff() {
|
||||
const v = $("#view");
|
||||
v.innerHTML = loading();
|
||||
let data;
|
||||
try { data = await api("/api/public/staff"); } catch (err) { v.innerHTML = errorBox(err); return; }
|
||||
if (state.page !== "staff") return;
|
||||
const rankRow = (g) => `<div class="crew-row" style="--c:${esc(g.color)}">
|
||||
<div class="crew-rank"><h2><span class="rank" style="font:inherit"><i></i>${esc(g.label)}</span></h2>${g.about ? `<p>${esc(g.about)}</p>` : ""}</div>
|
||||
<ul class="crew">
|
||||
${g.members.map((p) => `<li>
|
||||
<a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">
|
||||
<span class="avatar">${initial(p.name)}</span>
|
||||
<span><b>${esc(p.name)}</b><small>${p.online ? '<span class="dot"></span>On the server' : "Steam profile"}</small></span>
|
||||
</a>
|
||||
</li>`).join("")}
|
||||
</ul>
|
||||
</div>`;
|
||||
const staffGroups = data.groups.filter((g) => !g.supporter);
|
||||
const supporters = data.groups.filter((g) => g.supporter);
|
||||
const online = data.staffOnline;
|
||||
v.innerHTML = `
|
||||
<div class="page-head">
|
||||
<div><h1>Staff</h1><p class="sub">${online ? `${online} staff ${online === 1 ? "is" : "are"} on the server right now.` : "No staff are on the server right now."}</p></div>
|
||||
</div>
|
||||
${staffGroups.length ? `<section class="panel crew-panel">${staffGroups.map(rankRow).join("")}</section>` : '<div class="empty">No staff are listed yet.</div>'}
|
||||
${supporters.length ? `<h2 class="crew-sep">Supporters</h2><section class="panel crew-panel">${supporters.map(rankRow).join("")}</section>` : ""}`;
|
||||
}
|
||||
|
||||
/* ---------- Player lookup (read-only) ---------- */
|
||||
|
||||
async function openPlayer(sid) {
|
||||
$("#drawer-root").innerHTML = `<div class="scrim" data-close></div><aside class="drawer" role="dialog" aria-modal="true">${loading()}</aside>`;
|
||||
let p;
|
||||
try { p = await api(`/api/public/players/${encodeURIComponent(sid)}`); } catch (err) {
|
||||
$(".drawer").innerHTML = errorBox(err);
|
||||
return;
|
||||
}
|
||||
const history = [...p.bans, ...p.comms].sort((a, b) => new Date(b.created) - new Date(a.created));
|
||||
const now = history.filter((h) => h.status === "active");
|
||||
const label = (h) => (h.kind === "ban" ? "Ban" : COMM_LABEL[h.type]);
|
||||
const name = p.name || "Unknown player";
|
||||
$("#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><span class="sid">${esc(p.steamid)}</span>${p.rank ? `<div>${rankTag(p.rank)}</div>` : ""}</div>
|
||||
<button class="close" type="button" data-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<section>
|
||||
<h3>Right now</h3>
|
||||
${now.length ? `<div class="chips">${now.map((h) => `<span class="tag ${h.kind === "ban" ? "red" : "amber"}">${h.kind === "ban" ? "Banned" : COMM_HELP[h.type]}${h.duration ? `, ${fmtLeft(served(h).left)}` : ", permanently"}</span>`).join("")}</div>`
|
||||
: `<p class="muted" style="margin:0">${p.online ? "On the server, free to play and talk." : "Free to play and talk."}</p>`}
|
||||
<p style="margin:12px 0 0"><a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">Steam profile</a></p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>History</h3>
|
||||
${history.length ? `<ul class="history">${history.map((h) => `<li>
|
||||
<div class="top"><span><b>${label(h)}</b> <span class="muted">${fmtDate(h.created)}</span></span>${statusTag(h)}</div>
|
||||
<div>${esc(h.reason)}</div>
|
||||
${termCell(h)}
|
||||
</li>`).join("")}</ul>` : '<p class="muted" style="margin:0">No bans, gags or mutes on record.</p>'}
|
||||
</section>
|
||||
</aside>`;
|
||||
$(".drawer .close").focus();
|
||||
}
|
||||
|
||||
/* ---------- Routing & events ---------- */
|
||||
|
||||
const RENDER = { server, bans, comms, staff };
|
||||
|
||||
function route() {
|
||||
const page = location.hash.slice(1);
|
||||
state.page = RENDER[page] ? page : "server";
|
||||
$("#nav-links").innerHTML = PAGES.map((p) => `<li><a href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}</a></li>`).join("");
|
||||
RENDER[state.page]();
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", () => { state.query = ""; state.num = 1; route(); window.scrollTo(0, 0); });
|
||||
|
||||
const search = debounce(() => loadResults(), 250);
|
||||
document.addEventListener("input", (e) => {
|
||||
if (e.target.id === "q") { state.query = e.target.value; state.num = 1; search(); }
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const t = e.target;
|
||||
const seg = t.closest("[data-seg] button");
|
||||
if (seg) {
|
||||
state[seg.parentElement.dataset.seg] = seg.dataset.value;
|
||||
state.num = 1;
|
||||
seg.parentElement.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === seg)));
|
||||
loadResults();
|
||||
return;
|
||||
}
|
||||
const pg = t.closest("[data-page]");
|
||||
if (pg) { state.num = Number(pg.dataset.page); loadResults(); return; }
|
||||
if (t.closest("[data-close]")) { closeDrawer(); return; }
|
||||
const row = t.closest("[data-player]");
|
||||
if (row) openPlayer(row.dataset.player);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && $("#drawer-root").innerHTML) closeDrawer();
|
||||
});
|
||||
|
||||
api("/api/me").then((me) => {
|
||||
$("#site-name").innerHTML = siteName(me.site);
|
||||
document.title = `${me.site} bans and staff`;
|
||||
if (me.staff) $(".signin").innerHTML = "Staff panel";
|
||||
}).catch(() => {});
|
||||
|
||||
// Keep the scoreboard current while it's on screen.
|
||||
setInterval(() => { if (state.page === "server" && !document.hidden && !$("#drawer-root").innerHTML) server(); }, 15000);
|
||||
|
||||
route();
|
||||
Loading…
Add table
Add a link
Reference in a new issue