// 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 see the same as everyone; their extras are on the staff panel.
const PAGES = [
{ id: "server", label: "Server" },
// Once PlaytimeHistory has recorded something. Staff figures are on the staff panel only.
{ id: "playtime", label: "Playtime", show: () => state.me?.playtime === "on" },
{ 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))}. ${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 = `
The server is offline or restarting.
Offline
`;
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 `
${name} ${humans} ${humans === 1 ? "player" : "players"}${list.length > humans ? `, ${list.length - humans} ${list.length - humans === 1 ? "bot" : "bots"}` : ""}
${s.warmup ? '
Warmup ' : `
${score} ${score === 1 ? "round" : "rounds"} won
`}
${list.length ? `
Player Kills Deaths Ping
${list.map((p) => `
${esc(p.name)} ${p.bot ? 'Bot ' : p.rank ? rankTag(p.rank) : ""}
${p.kills}
${p.deaths}
${p.bot ? "" : p.ping}
`).join("")}
` : 'Nobody on this team.
'}
`;
};
const spectators = s.players.filter((p) => p.team !== 2 && p.team !== 3 && !p.bot);
v.innerHTML = `
${esc(s.hostname)}${s.warmup ? ", warmup" : ""}
${esc(s.map)}
${s.count}/${s.maxPlayers} Players
${s.scoreT} : ${s.scoreCt} ${s.warmup ? "Warmup, no rounds yet" : "Rounds won, T vs CT"}
${s.staffOnline} Staff online
${s.address ? `
Join server
or type connect ${esc(s.address)} in the console
` : ""}
${side(3, "Counter-Terrorists", s.scoreCt)}
${side(2, "Terrorists", s.scoreT)}
${spectators.length ? `Spectating: ${spectators.map((p) => esc(p.name)).join(", ")}
` : ""}`;
}
/* ---------- Bans and comms ---------- */
function publicTable(rows, kind) {
if (!rows.length) {
return `${state.query ? `Nobody matches "${esc(state.query)}". Try the full SteamID64.` : "Nothing in this view."}
`;
}
return `
Player
${kind === "comm" ? "Type " : ""}
Reason
Length
Issued
Status
${rows.map((p) => `
${esc(p.name || "Unknown")} ${esc(p.steamid)}
${kind === "comm" ? `${COMM_LABEL[p.type]} ` : ""}
${esc(p.reason)}
${termCell(p)}
${fmtDate(p.created)}
${statusTag(p)}
`).join("")}
`;
}
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);
} catch (err) {
box.innerHTML = errorBox(err);
}
}
function bans() {
$("#view").innerHTML = `
${searchBox("Search by name, SteamID64 or reason", state.query)}
${segmented("banFilter", state.banFilter, [["active", "Active"], ["expired", "Expired"], ["lifted", "Lifted"], ["all", "All"]])}
${loading()}
`;
loadResults();
}
function comms() {
$("#view").innerHTML = `
${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"]])}
${loading()}
`;
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) => `
${esc(g.label)}${g.about ? `
${esc(g.about)}
` : ""}
`;
const staffGroups = data.groups.filter((g) => !g.supporter);
const supporters = data.groups.filter((g) => g.supporter);
v.innerHTML = `
${staffGroups.length ? `${staffGroups.map(rankRow).join("")} ` : 'No staff are listed yet.
'}
${supporters.length ? `Supporters ${supporters.map(rankRow).join("")} ` : ""}
Rank changes
`;
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 = 'No rank changes recorded yet.
'; return; }
box.innerHTML = `
Player Change By When
${list.items.map((c) => {
const [label, tone] = RANK_CHANGE[c.kind] || [c.kind, ""];
const by = c.adminSteamid
? `${esc(c.admin)} `
: esc(c.admin);
return `
${esc(c.name || "Unknown")} ${esc(c.steamid)}
${label} ${rankTag(c.old)}→ ${rankTag(c.new)}
${by}${c.source === "game" ? ' in game ' : ""}
${fmtDate(c.created)}
`;
}).join("")}
` + pager(list);
}
/* ---------- Player lookup (read-only) ---------- */
async function openPlayer(sid) {
$("#drawer-root").innerHTML = `
`;
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 = `
${initial(name)}
${esc(name)} ${esc(p.steamid)} · Steam profile ${p.rank ? `
${rankTag(p.rank)}
` : ""}
×
${now.length || p.online ? `
${p.online ? 'On the server ' : ""}${now.map((h) => `${h.kind === "ban" ? "Banned" : COMM_HELP[h.type]}${h.duration ? `, ${fmtLeft(served(h).left)}` : ", permanently"} `).join("")}
` : ""}
${ptDrawer(p.playtime, p.online)}
History
${history.length ? `${history.map((h) => `
${label(h)} ${fmtDate(h.created)} ${statusTag(h)}
${esc(h.reason)}
${termCell(h)}
`).join("")} ` : 'No bans, gags or mutes on record.
'}
`;
$(".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= (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) => `${p.label} `).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) => `${p.label} `).join("");
});
// Keep the scoreboard current while it's on screen.
setInterval(() => { if (state.page === "server" && !document.hidden && !$("#drawer-root").innerHTML) server(); }, 15000);
route();