// 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))}.${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 ? ` ${list.map((p) => ``).join("")}
PlayerKillsDeathsPing
${esc(p.name)}${p.verified ? ` ${VERIFIED_TAG}` : ""}${p.bot ? 'Bot' : p.rank ? rankTag(p.rank) : ""}
${p.kills} ${p.deaths} ${p.bot ? "" : p.ping}
` : '
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 ` ${kind === "comm" ? "" : ""} ${rows.map((p) => ` ${kind === "comm" ? `` : ""} `).join("")}
PlayerTypeReason Length Issued Status
${esc(p.name || "Unknown")}${esc(p.steamid)}
${COMM_LABEL[p.type]}${esc(p.reason)} ${termCell(p)} ${fmtDate(p.created)} ${statusTag(p)}
`; } 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("")}
` : ""}

Rank changes

${loading()}
`; 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 = ` ${list.items.map((c) => { const [label, tone] = RANK_CHANGE[c.kind] || [c.kind, ""]; const by = c.adminSteamid ? `${esc(c.admin)}` : esc(c.admin); return ``; }).join("")}
PlayerChangeByWhen
${esc(c.name || "Unknown")}${esc(c.steamid)}
${label}${rankTag(c.old)}→${rankTag(c.new)}
${by}${c.source === "game" ? ' in game' : ""} ${fmtDate(c.created)}
` + 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 = `
`; $(".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();