// 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: '',
plus: '',
};
// 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 'Player';
return `${esc(rank.label)}`;
}
// 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 `${initial(name)}`;
}
// 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 ? "" : ``;
return `