Records: the most bomb plants, defuses, knife, Zeus and headshot kills for the last 7 days, 30 days or all time, and each player's counts, when PlaytimeHistory 0.2.0's pt_events is there. The public site's top bar now sticks the whole way down, Playtime comes after Server, and only the player drawer scrolls while it's open.
334 lines
16 KiB
JavaScript
334 lines
16 KiB
JavaScript
// Public, read-only site: the live server, bans, gags/mutes, staff with their rank changes, and
|
||
// playtime (playtime.js). The API leaves out who issued penalties and IPs; nothing here can change
|
||
// anything. Signed-in staff with @css/generic also see staff playtime figures here.
|
||
|
||
const PAGES = [
|
||
{ id: "server", label: "Server" },
|
||
// Once PlaytimeHistory has recorded something, or for staff who see its staff figures.
|
||
{ id: "playtime", label: "Playtime", show: () => state.me?.playtime === "on" || !!state.me?.playtimeStaff },
|
||
{ id: "bans", label: "Bans" },
|
||
{ id: "comms", label: "Gags & Mutes" },
|
||
{ id: "staff", label: "Staff" },
|
||
];
|
||
|
||
// me is null until /api/me answers.
|
||
const state = { page: "server", banFilter: "active", commFilter: "active", query: "", num: 1, me: null };
|
||
|
||
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.verified ? ` ${VERIFIED_TAG}` : ""}${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">
|
||
${avatar(p.steamid, p.name)}
|
||
<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>` : ""}
|
||
<h2 class="crew-sep">Rank changes</h2>
|
||
<section class="panel"><div class="table-wrap" id="rank-log">${loading()}</div></section>`;
|
||
loadRankLog();
|
||
}
|
||
|
||
// The database's added/removed are shown as promotions/demotions too: gaining or losing a rank.
|
||
const RANK_CHANGE = {
|
||
added: ["Promoted", "green"],
|
||
promoted: ["Promoted", "green"],
|
||
demoted: ["Demoted", "amber"],
|
||
changed: ["Moved", ""],
|
||
removed: ["Demoted", "amber"],
|
||
};
|
||
|
||
async function loadRankLog() {
|
||
const box = $("#rank-log");
|
||
if (!box) return;
|
||
const want = state.num;
|
||
loadRankLog.want = want;
|
||
let list;
|
||
try { list = await api("/api/public/ranks" + qs({ page: state.num })); } catch (err) { box.innerHTML = errorBox(err); return; }
|
||
if (loadRankLog.want !== want || !$("#rank-log")) return;
|
||
if (!list.items.length) { box.innerHTML = '<div class="empty">No rank changes recorded yet.</div>'; return; }
|
||
box.innerHTML = `<table>
|
||
<thead><tr><th>Player</th><th>Change</th><th class="hide-sm">By</th><th>When</th></tr></thead>
|
||
<tbody>
|
||
${list.items.map((c) => {
|
||
const [label, tone] = RANK_CHANGE[c.kind] || [c.kind, ""];
|
||
const by = c.adminSteamid
|
||
? `<a href="${steamProfile(c.adminSteamid)}" target="_blank" rel="noopener">${esc(c.admin)}</a>`
|
||
: esc(c.admin);
|
||
return `<tr class="clickable" data-player="${esc(c.steamid)}">
|
||
<td><div class="who"><div><b>${esc(c.name || "Unknown")}</b><span class="sid">${esc(c.steamid)}</span></div></div></td>
|
||
<td><div class="rank-move"><span class="tag ${tone}">${label}</span>${rankTag(c.old)}<span class="faint" aria-label="to">→</span>${rankTag(c.new)}</div></td>
|
||
<td class="hide-sm">${by}${c.source === "game" ? ' <span class="faint">in game</span>' : ""}</td>
|
||
<td>${fmtDate(c.created)}</td>
|
||
</tr>`;
|
||
}).join("")}
|
||
</tbody>
|
||
</table>` + pager(list);
|
||
}
|
||
|
||
/* ---------- 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 || p.verified ? `<section>
|
||
<div class="chips">${p.verified ? VERIFIED_TAG : ""}${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>` : ""}
|
||
${ptDrawer(p.playtime, p.online)}
|
||
<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 ---------- */
|
||
|
||
function playtime() {
|
||
if (!state.me) { $("#view").innerHTML = loading(); return; }
|
||
ptPage(state.me, () => state.page === "playtime");
|
||
}
|
||
|
||
const RENDER = { server, bans, comms, staff, playtime };
|
||
|
||
// A page is shown if it has no condition, or (once /api/me has answered) meets it.
|
||
const pageShown = (p) => !p.show || p.show();
|
||
|
||
function route() {
|
||
const [page, query] = location.hash.slice(1).split("?");
|
||
const def = PAGES.find((p) => p.id === page);
|
||
state.page = def && (pageShown(def) || !state.me) ? page : "server";
|
||
// A link like #bans?q=<steamid> (from a staff notification) opens the list searching for that
|
||
// player, showing every status so lifted and expired ones are there too.
|
||
const q = new URLSearchParams(query || "").get("q");
|
||
if (q && (state.page === "bans" || state.page === "comms")) {
|
||
state.query = q.slice(0, 64);
|
||
state.num = 1;
|
||
state[state.page === "bans" ? "banFilter" : "commFilter"] = "all";
|
||
}
|
||
$("#nav-links").innerHTML = PAGES.filter(pageShown).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 && seg.parentElement.dataset.seg.startsWith("pt")) return; // playtime.js
|
||
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); (state.page === "staff" ? loadRankLog : loadResults)(); return; }
|
||
if (t.closest("[data-close]")) { closeDrawer(); return; }
|
||
if (t.closest("a[href]")) 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) => {
|
||
state.me = me;
|
||
$("#site-name").innerHTML = siteName(me.site);
|
||
document.title = `${me.site} bans and staff`;
|
||
if (me.staff) $(".signin").innerHTML = "Staff panel";
|
||
}).catch(() => { state.me = {}; }).finally(() => {
|
||
// The Playtime tab depends on /api/me, so draw the nav (and a Playtime page) again.
|
||
if (state.page === "playtime") route();
|
||
else $("#nav-links").innerHTML = PAGES.filter(pageShown).map((p) => `<li><a href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}</a></li>`).join("");
|
||
});
|
||
|
||
// Keep the scoreboard current while it's on screen.
|
||
setInterval(() => { if (state.page === "server" && !document.hidden && !$("#drawer-root").innerHTML) server(); }, 15000);
|
||
|
||
route();
|