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.
176 lines
7.5 KiB
JavaScript
176 lines
7.5 KiB
JavaScript
// Helpers shared by the staff panel (admin.js) and the public site (public.js).
|
|
|
|
const $ = (sel, root = document) => root.querySelector(sel);
|
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
const MIN = 60 * 1000;
|
|
|
|
const ICON = {
|
|
search: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3 3"/></svg>',
|
|
chevron: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m6 4 4 4-4 4"/></svg>',
|
|
plus: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>',
|
|
};
|
|
|
|
// api calls the panel's JSON API and throws an Error carrying the server's message on failure.
|
|
async function api(path, { method = "GET", body } = {}) {
|
|
const opts = { method, headers: { "X-Requested-With": "simpleadmin-web" }, credentials: "same-origin" };
|
|
if (body !== undefined) {
|
|
opts.headers["Content-Type"] = "application/json";
|
|
opts.body = JSON.stringify(body);
|
|
}
|
|
let res;
|
|
try {
|
|
res = await fetch(path, opts);
|
|
} catch {
|
|
throw new Error("Couldn't reach the panel. Check your connection.");
|
|
}
|
|
let data = null;
|
|
try { data = await res.json(); } catch { /* empty or non-JSON body */ }
|
|
if (!res.ok) throw new Error(data?.error || `The panel answered ${res.status}.`);
|
|
return data;
|
|
}
|
|
|
|
function qs(params) {
|
|
const u = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") u.set(k, v);
|
|
const s = u.toString();
|
|
return s ? `?${s}` : "";
|
|
}
|
|
|
|
function debounce(fn, ms) {
|
|
let t;
|
|
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
|
}
|
|
|
|
function fmtDuration(min) {
|
|
if (min === 0) return "Permanent";
|
|
if (min < 60) return `${min} min`;
|
|
if (min < 1440) return `${Math.round(min / 60)} h`;
|
|
const d = Math.round(min / 1440);
|
|
return d === 1 ? "1 day" : `${d} days`;
|
|
}
|
|
|
|
function fmtAgo(iso) {
|
|
const m = Math.round((Date.now() - new Date(iso).getTime()) / MIN);
|
|
if (m < 1) return "now";
|
|
if (m < 60) return `${m} min ago`;
|
|
if (m < 1440) return `${Math.round(m / 60)} h ago`;
|
|
const d = Math.round(m / 1440);
|
|
return d === 1 ? "yesterday" : `${d} days ago`;
|
|
}
|
|
|
|
function fmtLeft(min) {
|
|
if (min < 60) return `${Math.max(1, Math.round(min))} min left`;
|
|
if (min < 1440) return `${Math.round(min / 60)} h left`;
|
|
return `${Math.round(min / 1440)} d left`;
|
|
}
|
|
|
|
function fmtDate(iso) {
|
|
return new Date(iso).toLocaleString(undefined, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
|
}
|
|
|
|
// Minutes served and left on a timed penalty. With SimpleAdmin's online-only time mode, "passed"
|
|
// counts minutes served.
|
|
function served(p) {
|
|
if (p.passed != null && p.status === "active" && !p.ends) return { done: p.passed, left: p.duration - p.passed };
|
|
const start = new Date(p.created).getTime();
|
|
const end = p.ends ? new Date(p.ends).getTime() : start + p.duration * MIN;
|
|
const done = (Math.min(Date.now(), end) - start) / MIN;
|
|
return { done, left: (end - Date.now()) / MIN };
|
|
}
|
|
|
|
function rankTag(rank) {
|
|
if (!rank) return '<span class="faint">Player</span>';
|
|
return `<span class="rank" style="--c:${esc(rank.color)}"><i></i>${esc(rank.label)}</span>`;
|
|
}
|
|
|
|
// A player's Steam avatar over their initial. /avatar only serves staff; for anyone else, or if
|
|
// Steam has none, the image fails and the initial shows (see the error listener below).
|
|
function avatar(sid, name, cls = "") {
|
|
return `<span class="avatar ${cls}">${initial(name)}<img src="/avatar/${encodeURIComponent(sid)}" alt="" loading="lazy"></span>`;
|
|
}
|
|
|
|
// Error events don't bubble, so catch them on the way down; the CSP rules out inline onerror.
|
|
document.addEventListener("error", (e) => {
|
|
if (e.target instanceof HTMLImageElement && e.target.parentElement?.classList.contains("avatar")) e.target.remove();
|
|
}, true);
|
|
|
|
function initial(name) { return esc(String(name || "").replace(/[^a-z0-9]/gi, "").charAt(0).toUpperCase() || "?"); }
|
|
|
|
// Time served against the full term: the bar under each length.
|
|
function termCell(p) {
|
|
const cls = ["term", p.kind === "comm" ? "comm" : ""];
|
|
let right;
|
|
let pct = 100;
|
|
if (p.duration === 0) {
|
|
cls.push("perm");
|
|
right = p.status === "lifted" ? "Lifted" : "Never expires";
|
|
if (p.status === "lifted") cls.push("lifted");
|
|
} else {
|
|
const s = served(p);
|
|
pct = Math.max(0, Math.min(100, (s.done / p.duration) * 100));
|
|
if (p.status === "active") right = fmtLeft(s.left);
|
|
else if (p.status === "expired") { right = "Served"; cls.push("done"); }
|
|
else { right = "Lifted"; cls.push("lifted"); }
|
|
}
|
|
const bar = p.duration === 0 ? "" : `<span style="width:${pct.toFixed(1)}%"></span>`;
|
|
return `<div class="${cls.join(" ")}">
|
|
<div class="term-top"><b>${fmtDuration(p.duration)}</b><span class="muted">${right}</span></div>
|
|
<div class="term-bar" role="img" aria-label="${esc(right)}">${bar}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function statusTag(p) {
|
|
if (p.status === "active") return '<span class="tag red">Active</span>';
|
|
if (p.status === "expired") return '<span class="tag">Expired</span>';
|
|
return '<span class="tag green">Lifted</span>';
|
|
}
|
|
|
|
const COMM_LABEL = { GAG: "Gag", MUTE: "Mute", SILENCE: "Silence" };
|
|
|
|
// Verified by GroupVerify: in the server's Steam group, or given an override by staff.
|
|
const VERIFIED_TAG = '<span class="tag verified" title="In the server\'s Steam group, or verified by staff">Verified</span>';
|
|
const COMM_HELP = { GAG: "Text chat blocked", MUTE: "Voice blocked", SILENCE: "Text and voice blocked" };
|
|
|
|
function segmented(name, value, options) {
|
|
return `<div class="segmented" role="group" data-seg="${name}">
|
|
${options.map(([v, l]) => `<button type="button" data-value="${v}" aria-pressed="${v === value}">${l}</button>`).join("")}
|
|
</div>`;
|
|
}
|
|
|
|
function searchBox(placeholder, value) {
|
|
return `<label class="search">${ICON.search}<input type="search" id="q" placeholder="${esc(placeholder)}" value="${esc(value)}" aria-label="${esc(placeholder)}" autocomplete="off"></label>`;
|
|
}
|
|
|
|
function pager(list) {
|
|
const pages = Math.max(1, Math.ceil(list.total / list.pageSize));
|
|
if (pages <= 1) return "";
|
|
return `<div class="pager">
|
|
<button class="btn sm ghost" type="button" data-page="${list.page - 1}" ${list.page <= 1 ? "disabled" : ""}>Newer</button>
|
|
<span class="muted">Page ${list.page} of ${pages}</span>
|
|
<button class="btn sm ghost" type="button" data-page="${list.page + 1}" ${list.page >= pages ? "disabled" : ""}>Older</button>
|
|
</div>`;
|
|
}
|
|
|
|
function loading() { return '<div class="empty">Loading…</div>'; }
|
|
|
|
function errorBox(err) { return `<div class="empty">${esc(err.message)}</div>`; }
|
|
|
|
function closeDrawer() { $("#drawer-root").innerHTML = ""; }
|
|
|
|
// Lock the page's own scrolling while the drawer is open, however it's opened or closed.
|
|
{
|
|
const root = $("#drawer-root");
|
|
if (root) new MutationObserver(() => document.documentElement.classList.toggle("drawer-open", root.innerHTML !== "")).observe(root, { childList: true });
|
|
}
|
|
|
|
function toast(msg) {
|
|
const root = $("#toast-root");
|
|
root.innerHTML = `<div class="toast">${esc(msg)}</div>`;
|
|
clearTimeout(toast.t);
|
|
toast.t = setTimeout(() => (root.innerHTML = ""), 3600);
|
|
}
|
|
|
|
const steamProfile = (sid) => `https://steamcommunity.com/profiles/${encodeURIComponent(sid)}`;
|
|
|
|
// The service worker makes the site installable as an app (see sw.js).
|
|
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js").catch(() => {});
|