(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(); })();