Files
PenaltyTracker/web/common.js
T

187 lines
6.5 KiB
JavaScript

// Shared helpers used by all pages.
const el = (tag, attrs, ...children) => {
const node = document.createElement(tag);
if (attrs) {
for (const k in attrs) {
if (k === "class") node.className = attrs[k];
else if (k === "style") Object.assign(node.style, attrs[k]);
else if (k.startsWith("on") && typeof attrs[k] === "function") node.addEventListener(k.slice(2), attrs[k]);
else if (k === "checked" || k === "disabled" || k === "selected") {
if (attrs[k]) node.setAttribute(k, "");
} else if (attrs[k] !== false && attrs[k] !== null && attrs[k] !== undefined) {
node.setAttribute(k, attrs[k]);
}
}
}
for (const c of children.flat()) {
if (c === null || c === undefined || c === false) continue;
node.appendChild(typeof c === "string" || typeof c === "number" ? document.createTextNode(String(c)) : c);
}
return node;
};
function clearNode(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
function naturalCompare(a, b) {
if (a == null) a = "";
if (b == null) b = "";
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
}
// Page navigation helpers (true multi-page navigation, not SPA routing).
const PAGES = {
login: "login.html",
competitions: "competitions.html",
competition: "competition.html",
forcePassword: "force-password.html",
};
function navigate(page, params) {
let url = PAGES[page] || page;
if (params) {
const qs = new URLSearchParams(params).toString();
if (qs) url += "?" + qs;
}
location.assign(url);
}
// Bootstraps a page: loads the current user, enforces auth rules.
// options:
// requireAuth: true — redirect to login if no session
// forbidIfMustChange: true — redirect to force-password.html
// onlyIfMustChange: true — redirect AWAY if user must NOT change
async function bootstrapAuth(options) {
options = options || {};
let user = null;
try {
user = await API.me();
} catch (e) {
user = null;
}
if (options.requireAuth && !user) {
navigate("login");
return null;
}
if (!user) return null;
setLang(user.language || CURRENT_LANG);
if (user.must_change_password && options.forbidIfMustChange) {
navigate("forcePassword");
return null;
}
if (!user.must_change_password && options.onlyIfMustChange) {
navigate("competitions");
return null;
}
return user;
}
// Standard topbar shown on authenticated pages.
function renderTopbar(user, opts) {
opts = opts || {};
const langSelect = el("select",
{ onchange: async (e) => {
const lang = e.target.value;
try { await API.updateMe({ language: lang }); } catch (_) {}
user.language = lang;
setLang(lang);
location.reload();
} },
...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === user.language }, I18N_NAMES[l]))
);
const logoutBtn = el("button", { class: "ghost", onclick: async () => {
try { await API.logout(); } catch (_) {}
navigate("login");
} }, t("logout"));
const brand = el("a", { href: PAGES.competitions, class: "brand" }, "Penalty Tracker");
const profileBtn = el("button", { class: "ghost", onclick: () => openProfileModal(user) },
user.display_name || user.username);
return el("div", { class: "topbar" },
brand,
el("div", { class: "nav" },
opts.extra || null,
profileBtn,
langSelect,
logoutBtn,
)
);
}
// Self-contained profile modal: language and password only. Username/display
// name are read-only here (only system admin can change them).
function openProfileModal(user) {
const backdrop = el("div", { class: "modal-backdrop",
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
const password = el("input", { type: "password", placeholder: t("leave_blank_keep") });
const lang = el("select", null,
...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === user.language }, I18N_NAMES[l]))
);
const err = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } });
const ok = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } });
const usernameField = el("input", { type: "text", value: user.username, disabled: true });
const displayField = el("input", { type: "text", value: user.display_name || "", disabled: true });
const modal = el("div", { class: "modal" },
el("h3", null, t("profile")),
el("div", { class: "field" }, el("label", null, t("username")), usernameField,
el("div", { class: "muted small" }, t("profile_username_readonly"))),
el("div", { class: "field" }, el("label", null, t("display_name")), displayField,
el("div", { class: "muted small" }, t("profile_displayname_readonly"))),
el("div", { class: "field" }, el("label", null, t("language")), lang),
el("div", { class: "field" }, el("label", null, t("new_password")), password),
err, ok,
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
el("button", { onclick: () => backdrop.remove() }, t("cancel")),
el("button", { class: "primary", onclick: async () => {
err.style.display = "none"; ok.style.display = "none";
const body = {};
if (lang.value !== user.language) body.language = lang.value;
if (password.value) body.password = password.value;
if (Object.keys(body).length === 0) { backdrop.remove(); return; }
try {
const u = await API.updateMe(body);
if (u.language !== user.language) {
user.language = u.language;
setLang(u.language);
}
ok.textContent = t("saved");
ok.style.display = "block";
setTimeout(() => { backdrop.remove(); location.reload(); }, 600);
} catch (e) {
err.textContent = (e.data && e.data.error) || e.message || "error";
err.style.display = "block";
}
} }, t("save")),
),
);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
}
// Read URL search params as an object.
function queryParams() {
const out = {};
const sp = new URLSearchParams(location.search);
for (const [k, v] of sp.entries()) out[k] = v;
return out;
}
// Initialize language from saved/browser preference before authenticated state
// is known.
(function initInitialLang() {
const userLang = navigator.language ? navigator.language.slice(0, 2) : "en";
if (typeof I18N_AVAILABLE !== "undefined" && I18N_AVAILABLE.includes(userLang)) {
setLang(userLang);
} else {
setLang("en");
}
})();