The score caption says "Warmup, no rounds yet" during warmup and "Rounds won, T vs CT" otherwise. Each team panel on the public page shows its player and bot count, and its score as rounds won, or a Warmup label during warmup. The top navigation scrolls sideways on narrow screens instead of overflowing.
268 lines
12 KiB
JavaScript
268 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)}</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();
|