Bans and comms saved without a player name (SimpleAdmin's offline css_addban etc.) show the player's latest name from sa_players_ips, and the panel writes that name onto new rows it claims. The mobile sidebar closes from a Close button, the backdrop or Escape, since it covers the Menu button. The public player drawer drops "Right now" and "Free to play and talk." and puts the Steam profile link after the SteamID. Adds @css/bypassipban to the permission checkboxes.
265 lines
12 KiB
JavaScript
265 lines
12 KiB
JavaScript
// 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);
|
||
const humans = list.filter((p) => !p.bot).length;
|
||
return `<section class="panel side ${team === 3 ? "ct" : "t"}">
|
||
<div class="panel-head">
|
||
<div><h2>${name}</h2><p>${humans} ${humans === 1 ? "player" : "players"}${list.length > humans ? `, ${list.length - humans} ${list.length - humans === 1 ? "bot" : "bots"}` : ""}</p></div>
|
||
${s.warmup ? '<span class="side-warmup">Warmup</span>' : `<div class="side-score"><b>${score}</b><small>${score === 1 ? "round" : "rounds"} won</small></div>`}
|
||
</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>${s.warmup ? "Warmup, no rounds yet" : "Rounds won, 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)} · <a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">Steam profile</a></span>${p.rank ? `<div>${rankTag(p.rank)}</div>` : ""}</div>
|
||
<button class="close" type="button" data-close aria-label="Close">×</button>
|
||
</div>
|
||
${now.length || p.online ? `<section>
|
||
<div class="chips">${p.online ? '<span class="tag">On the server</span>' : ""}${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>
|
||
</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();
|