152 lines
6.1 KiB
JavaScript
152 lines
6.1 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>',
|
|
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>`;
|
|
}
|
|
|
|
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" };
|
|
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 = ""; }
|
|
|
|
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)}`;
|