// 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 || {}; // Make sure English (fallback) and the browser-detected language are loaded // before we attempt to render anything. await setLang(detectInitialLang()); let user = null; try { user = await API.me(); } catch (e) { user = null; } if (options.requireAuth && !user) { navigate("login"); return null; } if (!user) return null; await 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; await 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; await 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; }