Add System Admin area and redesign Competitions page
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.
This commit is contained in:
+75
-22
@@ -37,6 +37,7 @@ const PAGES = {
|
||||
competitions: "competitions.html",
|
||||
competition: "competition.html",
|
||||
forcePassword: "force-password.html",
|
||||
admin: "admin.html",
|
||||
};
|
||||
|
||||
function navigate(page, params) {
|
||||
@@ -84,40 +85,56 @@ async function bootstrapAuth(options) {
|
||||
// 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) },
|
||||
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" },
|
||||
brand,
|
||||
el("div", { class: "row" }, brand, systemAdminBtn),
|
||||
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).
|
||||
// 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(); } });
|
||||
@@ -177,3 +194,39 @@ function queryParams() {
|
||||
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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user