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.
377 lines
15 KiB
JavaScript
377 lines
15 KiB
JavaScript
(async function () {
|
|
const root = document.getElementById("app");
|
|
const user = await bootstrapAuth({ requireAuth: true, forbidIfMustChange: true });
|
|
if (!user) return;
|
|
if (!user.is_system_admin) { navigate("competitions"); return; }
|
|
|
|
const section = queryParams().section === "rules" ? "rules" : "users";
|
|
|
|
const state = {
|
|
users: [],
|
|
langs: [], lang: null, numbers: [], rulesByNumber: {}, filter: "",
|
|
};
|
|
|
|
// ---- Users section -------------------------------------------------
|
|
|
|
async function loadUsers() {
|
|
state.users = await API.listUsers();
|
|
}
|
|
|
|
function openUserModal() {
|
|
const backdrop = el("div", { class: "modal-backdrop",
|
|
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
|
|
const username = el("input", { type: "text",
|
|
oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } });
|
|
const password = el("input", { type: "password" });
|
|
const displayName = el("input", { type: "text" });
|
|
const langSelect = el("select", null,
|
|
...I18N_AVAILABLE.map((l) => el("option", { value: l }, I18N_NAMES[l]))
|
|
);
|
|
const isAdmin = el("input", { type: "checkbox" });
|
|
backdrop.appendChild(el("div", { class: "modal" },
|
|
el("h3", null, t("add_user")),
|
|
el("div", { class: "field" }, el("label", null, t("username")), username),
|
|
el("div", { class: "field" }, el("label", null, t("password")), password),
|
|
el("div", { class: "field" }, el("label", null, t("display_name")), displayName),
|
|
el("div", { class: "field" }, el("label", null, t("language")), langSelect),
|
|
el("label", { class: "row" }, isAdmin, " " + t("is_admin")),
|
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
|
el("button", { onclick: () => backdrop.remove() }, t("cancel")),
|
|
el("button", { class: "primary", onclick: async () => {
|
|
if (!username.value.trim() || !password.value) return;
|
|
try {
|
|
await API.createUser({
|
|
username: username.value.trim().toLowerCase(),
|
|
password: password.value,
|
|
display_name: displayName.value,
|
|
language: langSelect.value,
|
|
is_system_admin: isAdmin.checked,
|
|
});
|
|
backdrop.remove();
|
|
await loadUsers();
|
|
render();
|
|
} catch (err) { alert(err.message); }
|
|
} }, t("create")),
|
|
),
|
|
));
|
|
document.body.appendChild(backdrop);
|
|
}
|
|
|
|
function openEditUserModal(u) {
|
|
const backdrop = el("div", { class: "modal-backdrop",
|
|
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
|
|
const username = el("input", { type: "text", value: u.username,
|
|
oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } });
|
|
const displayName = el("input", { type: "text", value: u.display_name || "" });
|
|
const password = el("input", { type: "password", placeholder: t("leave_blank_keep") });
|
|
const isAdmin = el("input", { type: "checkbox", checked: !!u.is_system_admin });
|
|
backdrop.appendChild(el("div", { class: "modal" },
|
|
el("h3", null, t("edit") + ": " + u.username),
|
|
el("div", { class: "field" }, el("label", null, t("username")), username),
|
|
el("div", { class: "field" }, el("label", null, t("display_name")), displayName),
|
|
el("div", { class: "field" }, el("label", null, t("new_password")), password),
|
|
u.id !== user.id && el("label", { class: "row" }, isAdmin, " " + t("is_admin")),
|
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
|
el("button", { onclick: () => backdrop.remove() }, t("cancel")),
|
|
el("button", { class: "primary", onclick: async () => {
|
|
const body = {};
|
|
const newUsername = username.value.trim().toLowerCase();
|
|
if (newUsername && newUsername !== u.username) body.username = newUsername;
|
|
if (displayName.value !== (u.display_name || "")) body.display_name = displayName.value;
|
|
if (password.value) body.password = password.value;
|
|
if (u.id !== user.id && isAdmin.checked !== !!u.is_system_admin) body.is_system_admin = isAdmin.checked;
|
|
if (Object.keys(body).length === 0) { backdrop.remove(); return; }
|
|
try {
|
|
await API.updateUser(u.id, body);
|
|
backdrop.remove();
|
|
await loadUsers();
|
|
render();
|
|
} catch (e) { alert((e.data && e.data.error) || e.message); }
|
|
} }, t("save")),
|
|
),
|
|
));
|
|
document.body.appendChild(backdrop);
|
|
}
|
|
|
|
function renderUsersSection() {
|
|
const wrap = el("div");
|
|
wrap.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.75rem" } },
|
|
el("h2", { style: { margin: 0 } }, t("user_management")),
|
|
el("button", { class: "primary", onclick: openUserModal }, t("add_user")),
|
|
));
|
|
if (state.users.length === 0) {
|
|
wrap.appendChild(el("div", { class: "muted" }, "—"));
|
|
return wrap;
|
|
}
|
|
const table = el("table");
|
|
table.appendChild(el("thead", null,
|
|
el("tr", null,
|
|
el("th", null, t("username")),
|
|
el("th", null, t("display_name")),
|
|
el("th", null, t("language")),
|
|
el("th", null, t("is_admin")),
|
|
el("th", null, t("must_change_password")),
|
|
el("th", null, t("actions")),
|
|
)
|
|
));
|
|
const tbody = el("tbody");
|
|
for (const u of state.users) {
|
|
tbody.appendChild(el("tr", null,
|
|
el("td", null, u.username),
|
|
el("td", null, u.display_name),
|
|
el("td", null, I18N_NAMES[u.language] || u.language),
|
|
el("td", null, u.is_system_admin ? t("yes") : t("no")),
|
|
el("td", null, u.must_change_password ? el("span", { class: "badge warn" }, t("yes")) : t("no")),
|
|
el("td", null,
|
|
el("button", { class: "action-btn", onclick: () => openEditUserModal(u) }, t("edit")),
|
|
!u.must_change_password && el("button", { class: "action-btn", onclick: async () => {
|
|
if (!confirm(t("confirm_force_password"))) return;
|
|
await API.updateUser(u.id, { must_change_password: true });
|
|
await loadUsers();
|
|
render();
|
|
} }, t("force_password_change")),
|
|
u.id !== user.id && el("button", { class: "action-btn danger", onclick: async () => {
|
|
if (!confirm(t("confirm_delete"))) return;
|
|
await API.deleteUser(u.id);
|
|
await loadUsers();
|
|
render();
|
|
} }, t("delete")),
|
|
),
|
|
));
|
|
}
|
|
table.appendChild(tbody);
|
|
wrap.appendChild(el("div", { class: "table-wrap" }, table));
|
|
return wrap;
|
|
}
|
|
|
|
// ---- Rules section --------------------------------------------------
|
|
|
|
function rawEscalation(rule) {
|
|
if (!rule) return "";
|
|
if (rule.escalation_mode === "escalate") return "escalate:" + (rule.escalation_tiers || []).join("|");
|
|
return rule.escalation_mode || "";
|
|
}
|
|
|
|
async function loadLangs() {
|
|
let langs = [];
|
|
try { langs = await API.listRuleLanguages(); } catch (e) {}
|
|
state.langs = (langs && langs.length ? langs : ["en"]).slice().sort();
|
|
if (!state.lang || !state.langs.includes(state.lang)) {
|
|
state.lang = state.langs.includes(user.language) ? user.language : state.langs[0];
|
|
}
|
|
}
|
|
|
|
async function loadNumbers() {
|
|
try { state.numbers = await API.listRuleNumbers(); } catch (e) { state.numbers = []; }
|
|
}
|
|
|
|
async function loadRulesForLang() {
|
|
const list = await API.listRules(state.lang);
|
|
state.rulesByNumber = {};
|
|
for (const r of list) state.rulesByNumber[r.number] = r;
|
|
for (const n of Object.keys(state.rulesByNumber)) {
|
|
if (!state.numbers.includes(n)) state.numbers.push(n);
|
|
}
|
|
}
|
|
|
|
function renderRuleRow(tbody, number) {
|
|
const rule = state.rulesByNumber[number];
|
|
const original = {
|
|
text: (rule && rule.text) || "",
|
|
suggested_penalty: (rule && rule.suggested_penalty) || "",
|
|
escalation_mode: rawEscalation(rule),
|
|
};
|
|
|
|
const textInput = el("textarea", { rows: "2", style: { width: "100%" } }, original.text);
|
|
const penaltyInput = el("input", { type: "text", style: { width: "100%" }, value: original.suggested_penalty });
|
|
const escInput = el("input", { type: "text", style: { width: "100%" }, placeholder: "same", value: original.escalation_mode });
|
|
const err = el("div", { class: "muted small", style: { color: "var(--danger)", display: "none" } });
|
|
const saveBtn = el("button", { class: "action-btn", disabled: true }, t("save"));
|
|
|
|
function isDirty() {
|
|
return textInput.value !== original.text
|
|
|| penaltyInput.value !== original.suggested_penalty
|
|
|| escInput.value !== original.escalation_mode;
|
|
}
|
|
function refreshDirty() {
|
|
saveBtn.disabled = !isDirty();
|
|
err.style.display = "none";
|
|
}
|
|
textInput.addEventListener("input", refreshDirty);
|
|
penaltyInput.addEventListener("input", refreshDirty);
|
|
escInput.addEventListener("input", refreshDirty);
|
|
|
|
saveBtn.addEventListener("click", async () => {
|
|
const text = textInput.value.trim();
|
|
if (!text) {
|
|
err.textContent = t("missing_fields");
|
|
err.style.display = "block";
|
|
return;
|
|
}
|
|
saveBtn.disabled = true;
|
|
try {
|
|
const updated = await API.updateRule(state.lang, number, {
|
|
text,
|
|
suggested_penalty: penaltyInput.value.trim(),
|
|
escalation_mode: escInput.value.trim(),
|
|
});
|
|
state.rulesByNumber[number] = updated;
|
|
original.text = updated.text;
|
|
original.suggested_penalty = updated.suggested_penalty;
|
|
original.escalation_mode = rawEscalation(updated);
|
|
textInput.value = original.text;
|
|
penaltyInput.value = original.suggested_penalty;
|
|
escInput.value = original.escalation_mode;
|
|
const prevLabel = saveBtn.textContent;
|
|
saveBtn.textContent = t("saved");
|
|
setTimeout(() => { saveBtn.textContent = prevLabel; }, 900);
|
|
} catch (e) {
|
|
err.textContent = (e.data && e.data.error) || e.message;
|
|
err.style.display = "block";
|
|
saveBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
tbody.appendChild(el("tr", null,
|
|
el("td", { class: "col-num" },
|
|
el("span", { class: "rule-num" }, number),
|
|
!rule ? el("div", { class: "muted small" }, t("missing_translation")) : null,
|
|
),
|
|
el("td", { class: "col-text" }, textInput),
|
|
el("td", { class: "col-suggested" }, penaltyInput),
|
|
el("td", { class: "col-escalation" }, escInput, err),
|
|
el("td", null, saveBtn),
|
|
));
|
|
}
|
|
|
|
function renderRulesTable(container) {
|
|
const tableWrap = el("div", { class: "table-wrap" });
|
|
const table = el("table", { class: "rules-table" });
|
|
table.appendChild(el("thead", null,
|
|
el("tr", null,
|
|
el("th", { class: "col-num" }, t("rule_number_short")),
|
|
el("th", { class: "col-text" }, t("rule")),
|
|
el("th", { class: "col-suggested" }, t("suggested_penalty")),
|
|
el("th", { class: "col-escalation", title: t("escalation_mode_hint") }, t("escalation")),
|
|
el("th", null, t("actions")),
|
|
)
|
|
));
|
|
const tbody = el("tbody");
|
|
table.appendChild(tbody);
|
|
tableWrap.appendChild(table);
|
|
|
|
const q = state.filter.trim().toLowerCase();
|
|
const numbers = [...new Set(state.numbers)]
|
|
.filter((n) => {
|
|
if (!q) return true;
|
|
const r = state.rulesByNumber[n];
|
|
return n.toLowerCase().includes(q) || ((r && r.text) || "").toLowerCase().includes(q);
|
|
})
|
|
.sort(naturalCompare);
|
|
|
|
if (numbers.length === 0) {
|
|
tbody.appendChild(el("tr", null, el("td", { colspan: "5", class: "muted" }, t("none"))));
|
|
} else {
|
|
for (const n of numbers) renderRuleRow(tbody, n);
|
|
}
|
|
container.appendChild(tableWrap);
|
|
}
|
|
|
|
function renderRulesSection() {
|
|
const wrap = el("div");
|
|
wrap.appendChild(el("div", { style: { marginBottom: "0.75rem" } },
|
|
el("h2", { style: { margin: "0 0 0.25rem 0" } }, t("rule_management")),
|
|
el("div", { class: "muted small" }, t("rules_admin_hint")),
|
|
));
|
|
|
|
const langSelect = el("select", { title: t("rules_language"), "aria-label": t("rules_language"), onchange: async (e) => {
|
|
state.lang = e.target.value;
|
|
await loadRulesForLang();
|
|
render();
|
|
} },
|
|
...state.langs.map((l) => el("option", { value: l, selected: l === state.lang }, I18N_NAMES[l] || l))
|
|
);
|
|
|
|
const search = el("input", { type: "search", placeholder: t("search_rule"), style: { flex: "1", minWidth: "220px" },
|
|
value: state.filter,
|
|
oninput: (e) => { state.filter = e.target.value; render(); } });
|
|
|
|
const newNumberInput = el("input", { type: "text", placeholder: t("new_rule_number"), style: { width: "10rem" } });
|
|
const addBtn = el("button", { onclick: () => {
|
|
const n = newNumberInput.value.trim();
|
|
if (!n) return;
|
|
if (state.numbers.includes(n)) { alert(t("rule_number_exists")); return; }
|
|
state.numbers.push(n);
|
|
state.filter = "";
|
|
newNumberInput.value = "";
|
|
render();
|
|
} }, t("add_rule"));
|
|
|
|
wrap.appendChild(el("div", { class: "toolbar" },
|
|
langSelect,
|
|
search,
|
|
el("div", { class: "spacer" }),
|
|
newNumberInput,
|
|
addBtn,
|
|
el("button", { class: "ghost", onclick: async () => {
|
|
await API.reloadRules();
|
|
await loadLangs();
|
|
await loadNumbers();
|
|
await loadRulesForLang();
|
|
render();
|
|
} }, t("reload_from_disk")),
|
|
));
|
|
|
|
renderRulesTable(wrap);
|
|
return wrap;
|
|
}
|
|
|
|
// ---- Sidebar + shell --------------------------------------------------
|
|
|
|
function renderSidebarCategory(categoryLabel, subItems) {
|
|
const isActive = subItems.some((si) => si.key === section);
|
|
const cat = el("div", { class: "category" + (isActive ? "" : " collapsed") });
|
|
const list = el("ul", { class: "sub-list" },
|
|
...subItems.map((si) => el("li", null,
|
|
el("div", { class: "sub-item" + (si.key === section ? " active" : ""),
|
|
onclick: () => navigate("admin", { section: si.key }) },
|
|
si.label)
|
|
))
|
|
);
|
|
const header = el("button", { class: "category-header", onclick: () => cat.classList.toggle("collapsed") },
|
|
el("span", null, categoryLabel),
|
|
el("span", { class: "chevron" }, "▾"),
|
|
);
|
|
cat.appendChild(header);
|
|
cat.appendChild(list);
|
|
return cat;
|
|
}
|
|
|
|
function renderSidebar() {
|
|
return el("div", { class: "admin-sidebar" },
|
|
renderSidebarCategory(t("users"), [{ key: "users", label: t("user_management") }]),
|
|
renderSidebarCategory(t("rules"), [{ key: "rules", label: t("rule_management") }]),
|
|
);
|
|
}
|
|
|
|
function render() {
|
|
clearNode(root);
|
|
root.appendChild(renderTopbar(user));
|
|
const shell = el("div", { class: "admin-shell" });
|
|
shell.appendChild(renderSidebar());
|
|
const content = el("div", { class: "admin-content" },
|
|
section === "users" ? renderUsersSection() : renderRulesSection());
|
|
shell.appendChild(content);
|
|
root.appendChild(shell);
|
|
}
|
|
|
|
if (section === "users") {
|
|
await loadUsers();
|
|
} else {
|
|
await loadLangs();
|
|
await loadNumbers();
|
|
await loadRulesForLang();
|
|
}
|
|
render();
|
|
})();
|