// 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))}.${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);
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 = `
Bans
Search for a name or SteamID64 to check a ban and when it ends.
${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 = `
Gags & mutes
A gag blocks text chat, a mute blocks voice, and a silence blocks both. Blocks carry over between maps and reconnects.
${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);
const online = data.staffOnline;
v.innerHTML = `
Staff
${online ? `${online} staff ${online === 1 ? "is" : "are"} on the server right now.` : "No staff are on the server right now."}
${staffGroups.length ? `${staffGroups.map(rankRow).join("")}` : 'No staff are listed yet.
'}
${supporters.length ? `Supporters
${supporters.map(rankRow).join("")}` : ""}`;
}
/* ---------- 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 = `
`;
$(".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) => `${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) {
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();