Move the Users table and add a new rule-text editor into a dedicated admin.html/admin.js page with its own collapsible sidebar (Users, Rules categories), reachable only for system admins via a button next to the brand. The Competitions page no longer renders admin-only controls inline. Competitions page: cards are now fully clickable to open the competition (drop the separate "Open" button), the admin-only delete action moves into a "..." menu (new generic openInlineMenu/ openAnchoredPopover helpers in common.js), and add an open/closed filter. Topbar: language switch and logout move out of the topbar into a small popup under the profile button, which itself opens the full settings modal for username/display name/language/password.
233 lines
8.6 KiB
JavaScript
233 lines
8.6 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",
|
|
admin: "admin.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 brand = el("a", { href: PAGES.competitions, class: "brand" }, "Penalty Tracker");
|
|
|
|
const systemAdminBtn = user.is_system_admin
|
|
? el("button", { class: "ghost", onclick: () => navigate("admin") }, t("system_admin_area"))
|
|
: null;
|
|
|
|
const profileBtn = el("button", { class: "ghost", onclick: (e) => openProfileMenu(e.currentTarget, user) },
|
|
user.display_name || user.username);
|
|
|
|
return el("div", { class: "topbar" },
|
|
el("div", { class: "row" }, brand, systemAdminBtn),
|
|
el("div", { class: "nav" },
|
|
opts.extra || null,
|
|
profileBtn,
|
|
)
|
|
);
|
|
}
|
|
|
|
// Small popup anchored below the profile button: display name, a quick
|
|
// language switch, a link into the full settings modal, and logout.
|
|
function openProfileMenu(anchorEl, user) {
|
|
openAnchoredPopover(anchorEl, (menu) => {
|
|
menu.appendChild(el("div", { style: { padding: "0.4rem 0.6rem", fontWeight: "600" } },
|
|
user.display_name || user.username));
|
|
|
|
const langSelect = el("select", { style: { width: "100%" }, onchange: async (e) => {
|
|
const lang = e.target.value;
|
|
try { await API.updateMe({ language: lang }); } catch (_) {}
|
|
user.language = lang;
|
|
await setLang(lang);
|
|
menu.remove();
|
|
location.reload();
|
|
} },
|
|
...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === user.language }, I18N_NAMES[l]))
|
|
);
|
|
menu.appendChild(el("div", { style: { padding: "0 0.6rem 0.5rem" } }, langSelect));
|
|
|
|
menu.appendChild(el("div", { style: { borderTop: "1px solid var(--border)", margin: "0.25rem 0" } }));
|
|
|
|
menu.appendChild(el("button", { onclick: (e) => { e.stopPropagation(); menu.remove(); openProfileModal(user); } }, t("settings")));
|
|
menu.appendChild(el("button", { onclick: async (e) => {
|
|
e.stopPropagation();
|
|
try { await API.logout(); } catch (_) {}
|
|
navigate("login");
|
|
} }, t("logout")));
|
|
}, { width: 220 });
|
|
}
|
|
|
|
// Full settings modal: username/display name (read-only here — only a system
|
|
// admin can change them), language, and password change.
|
|
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;
|
|
}
|
|
|
|
// Small fixed-position popup anchored below `anchorEl`. `build(menu)` fills
|
|
// in the content; the popup closes itself on an outside click.
|
|
function openAnchoredPopover(anchorEl, build, opts) {
|
|
opts = opts || {};
|
|
document.querySelectorAll(".menu").forEach((m) => m.remove());
|
|
const rect = anchorEl.getBoundingClientRect();
|
|
const width = opts.width || 150;
|
|
const menu = el("div", { class: "menu",
|
|
style: { top: (rect.bottom + 4) + "px", left: Math.max(8, rect.right - width) + "px", minWidth: width + "px" } });
|
|
build(menu);
|
|
document.body.appendChild(menu);
|
|
setTimeout(() => {
|
|
const close = (e) => {
|
|
if (!menu.contains(e.target)) {
|
|
menu.remove();
|
|
document.removeEventListener("click", close);
|
|
}
|
|
};
|
|
document.addEventListener("click", close);
|
|
}, 0);
|
|
return menu;
|
|
}
|
|
|
|
// Small dropdown menu of simple actions anchored below `anchorEl`.
|
|
// items: [{ label, danger?: boolean, onClick: () => void }]
|
|
function openInlineMenu(anchorEl, items) {
|
|
openAnchoredPopover(anchorEl, (menu) => {
|
|
for (const item of items) {
|
|
menu.appendChild(el("button", {
|
|
class: item.danger ? "danger" : "",
|
|
onclick: (e) => { e.stopPropagation(); menu.remove(); item.onClick(); },
|
|
}, item.label));
|
|
}
|
|
});
|
|
}
|
|
|