From ae035c3fd4e9edb4ada77ab939bd6fde1b406216 Mon Sep 17 00:00:00 2001 From: Jan Meinl Date: Sat, 5 Sep 2026 13:33:05 +0200 Subject: [PATCH] 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. --- web/admin.html | 17 ++ web/admin.js | 376 ++++++++++++++++++++++++++++++++++++++++++++ web/api.js | 3 + web/common.js | 97 +++++++++--- web/competitions.js | 198 ++++++----------------- web/i18n/de.json | 13 +- web/i18n/en.json | 13 +- web/style.css | 87 ++++++++++ 8 files changed, 631 insertions(+), 173 deletions(-) create mode 100644 web/admin.html create mode 100644 web/admin.js diff --git a/web/admin.html b/web/admin.html new file mode 100644 index 0000000..66b2d17 --- /dev/null +++ b/web/admin.html @@ -0,0 +1,17 @@ + + + + + +Penalty Tracker — System Admin + + + +
+ + + + + + + diff --git a/web/admin.js b/web/admin.js new file mode 100644 index 0000000..d22cf41 --- /dev/null +++ b/web/admin.js @@ -0,0 +1,376 @@ +(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(); +})(); diff --git a/web/api.js b/web/api.js index 10c623a..a84e7cc 100644 --- a/web/api.js +++ b/web/api.js @@ -81,6 +81,9 @@ const API = { listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`), listRuleLanguages: () => api("GET", "/api/rules/languages"), + listRuleNumbers: () => api("GET", "/api/rules/numbers"), + updateRule: (lang, number, b) => api("PUT", `/api/rules/${encodeURIComponent(lang)}/${encodeURIComponent(number)}`, b), + reloadRules: () => api("POST", "/api/rules/reload"), }; function openCompetitionWS(id, handlers) { diff --git a/web/common.js b/web/common.js index fa6bd87..33179a3 100644 --- a/web/common.js +++ b/web/common.js @@ -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)); + } + }); +} + diff --git a/web/competitions.js b/web/competitions.js index 3a5548a..cb10f41 100644 --- a/web/competitions.js +++ b/web/competitions.js @@ -3,14 +3,11 @@ const user = await bootstrapAuth({ requireAuth: true, forbidIfMustChange: true }); if (!user) return; - const state = { competitions: [], users: [] }; + const state = { competitions: [], filter: "all" }; async function loadCompetitions() { state.competitions = await API.listCompetitions(); } - async function loadUsers() { - if (user.is_system_admin) state.users = await API.listUsers(); - } async function openCompetitionModal() { const backdrop = el("div", { class: "modal-backdrop", @@ -53,178 +50,81 @@ document.body.appendChild(backdrop); } - 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 openCardMenu(anchorEl, c) { + openInlineMenu(anchorEl, [ + { label: t("delete"), danger: true, onClick: async () => { + if (!confirm(t("confirm_delete_competition_named", { name: c.name }))) return; + try { + await API.deleteCompetition(c.id); + await loadCompetitions(); + render(); + } catch (e) { alert((e.data && e.data.error) || e.message); } + } }, + ]); } - function renderUsersAdmin() { - const card = el("div", { class: "card" }); - card.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } }, - el("h2", { style: { margin: 0 } }, t("users")), - el("button", { onclick: openUserModal }, t("add_user")), - )); - if (state.users.length === 0) { - card.appendChild(el("div", { class: "muted" }, "—")); - return card; - } - 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) { - const row = 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")), - ), - ); - tbody.appendChild(row); - } - table.appendChild(tbody); - card.appendChild(el("div", { class: "table-wrap" }, table)); - return card; - } - - 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 matchesFilter(c) { + if (state.filter === "open") return !c.closed; + if (state.filter === "closed") return !!c.closed; + return true; } function render() { clearNode(root); root.appendChild(renderTopbar(user)); const container = el("div", { class: "container" }); + + const filterSelect = el("select", { onchange: (e) => { state.filter = e.target.value; render(); } }, + el("option", { value: "all", selected: state.filter === "all" }, t("filter_all")), + el("option", { value: "open", selected: state.filter === "open" }, t("open")), + el("option", { value: "closed", selected: state.filter === "closed" }, t("closed")), + ); + container.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.75rem" } }, el("h2", { style: { margin: 0 } }, t("competitions")), - user.is_system_admin && el("button", { class: "primary", onclick: openCompetitionModal }, t("new_competition")), + el("div", { class: "row" }, + filterSelect, + user.is_system_admin && el("button", { class: "primary", onclick: openCompetitionModal }, t("new_competition")), + ), )); - if (state.competitions.length === 0) { + const filtered = state.competitions.filter(matchesFilter); + + if (filtered.length === 0) { container.appendChild(el("div", { class: "muted" }, t("no_competitions"))); } else { const grid = el("div", { class: "grid" }); - for (const c of state.competitions) { - grid.appendChild(el("div", { class: "card" }, - el("h2", null, c.name), - el("div", { class: "muted", style: { marginBottom: "0.5rem" } }, + for (const c of filtered) { + const menuBtn = user.is_system_admin + ? el("button", { class: "ghost action-btn", title: t("actions"), onclick: (e) => { + e.stopPropagation(); + openCardMenu(e.currentTarget, c); + } }, "⋮") + : null; + + grid.appendChild(el("div", { + class: "card clickable", + tabindex: "0", + onclick: () => navigate("competition", { id: c.id }), + onkeydown: (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); navigate("competition", { id: c.id }); } }, + }, + el("div", { class: "row", style: { justifyContent: "space-between", alignItems: "center", flexWrap: "nowrap" } }, + el("h2", { title: c.name, style: { margin: 0, minWidth: 0, flex: "1", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, c.name), + menuBtn && el("div", { style: { flexShrink: "0" } }, menuBtn), + ), + el("div", { class: "muted", style: { marginTop: "0.5rem" } }, el("span", { class: "badge accent" }, t(c.role)), c.closed ? " " : null, c.closed ? el("span", { class: "badge warn" }, t("closed")) : null, ), - el("div", { class: "row", style: { gap: "0.5rem" } }, - el("button", { class: "primary", onclick: () => navigate("competition", { id: c.id }) }, t("open")), - user.is_system_admin && el("button", { class: "danger", onclick: async () => { - if (!confirm(t("confirm_delete_competition_named", { name: c.name }))) return; - try { - await API.deleteCompetition(c.id); - await loadCompetitions(); - render(); - } catch (e) { alert((e.data && e.data.error) || e.message); } - } }, t("delete")), - ), )); } container.appendChild(grid); } - if (user.is_system_admin) { - container.appendChild(renderUsersAdmin()); - } root.appendChild(container); } await loadCompetitions(); - await loadUsers(); render(); })(); diff --git a/web/i18n/de.json b/web/i18n/de.json index ad2db9f..278959d 100644 --- a/web/i18n/de.json +++ b/web/i18n/de.json @@ -145,5 +145,16 @@ "competition_closed": "Wettbewerb ist beendet", "upload_file": "Datei hochladen", "csv_separator_hint": "Komma (,) und Semikolon (;) werden automatisch erkannt.", - "csv_empty": "Bitte CSV einfügen oder eine Datei wählen." + "csv_empty": "Bitte CSV einfügen oder eine Datei wählen.", + "system_admin_area": "System Admin", + "user_management": "Benutzerverwaltung", + "rule_management": "Regelverwaltung", + "rules_admin_hint": "Regeltext, Strafvorschlag und Eskalationsverhalten pro Sprache bearbeiten. Beim Speichern wird direkt in die Regeldatei der jeweiligen Sprache geschrieben.", + "reload_from_disk": "Von Datei neu laden", + "missing_translation": "Für diese Sprache noch kein Text vorhanden", + "escalation_mode_hint": "same, doubled, oder escalate:stufe1|stufe2|…", + "new_rule_number": "Neue Regelnummer", + "add_rule": "Regel hinzufügen", + "rule_number_exists": "Diese Regelnummer existiert bereits — bitte in der Tabelle unten bearbeiten.", + "missing_fields": "Bitte alle erforderlichen Felder ausfüllen" } diff --git a/web/i18n/en.json b/web/i18n/en.json index b2f10d1..bca840f 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -145,5 +145,16 @@ "competition_closed": "Competition is closed", "upload_file": "Upload file", "csv_separator_hint": "Comma (,) and semicolon (;) are detected automatically.", - "csv_empty": "Please paste CSV or choose a file." + "csv_empty": "Please paste CSV or choose a file.", + "system_admin_area": "System Admin", + "user_management": "User management", + "rule_management": "Rule management", + "rules_admin_hint": "Edit the rule text, suggested penalty and escalation setting shown to scorers, per language. Saving writes directly to that language's rule file.", + "reload_from_disk": "Reload from disk", + "missing_translation": "No text yet for this language", + "escalation_mode_hint": "same, doubled, or escalate:tier1|tier2|…", + "new_rule_number": "New rule number", + "add_rule": "Add rule", + "rule_number_exists": "This rule number already exists — edit it in the table below.", + "missing_fields": "Please fill in all required fields" } diff --git a/web/style.css b/web/style.css index 8be6bc3..7b8c552 100644 --- a/web/style.css +++ b/web/style.css @@ -152,6 +152,7 @@ th { background: #fafafa; position: sticky; top: 0; + z-index: 2; cursor: pointer; user-select: none; white-space: nowrap; @@ -450,3 +451,89 @@ button[disabled] { opacity: 0.55; cursor: not-allowed; } .rules-table th.col-escalation, .rules-table td.col-escalation { width: 10rem; } } + +/* System Admin area: sidebar + content, own layout separate from the + normal topbar-only pages. */ +.admin-shell { + display: flex; + align-items: flex-start; +} +.admin-sidebar { + width: 220px; + flex-shrink: 0; + border-right: 1px solid var(--border); + padding: 1rem 0.5rem; + min-height: calc(100vh - 57px); +} +.admin-sidebar .category { margin-bottom: 0.25rem; } +.admin-sidebar .category-header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: transparent; + border: none; + border-radius: var(--radius); + padding: 0.5rem 0.6rem; + font-weight: 600; + cursor: pointer; +} +.admin-sidebar .category-header:hover { background: var(--row-hover); } +.admin-sidebar .category-header .chevron { + color: var(--muted); + font-size: 0.7rem; + transition: transform 0.15s ease; +} +.admin-sidebar .category.collapsed .chevron { transform: rotate(-90deg); } +.admin-sidebar .category.collapsed .sub-list { display: none; } +.admin-sidebar .sub-list { list-style: none; margin: 0.15rem 0 0.5rem 0; padding: 0; } +.admin-sidebar .sub-item { + display: block; + padding: 0.4rem 0.6rem 0.4rem 1.25rem; + border-radius: var(--radius); + font-size: 0.9rem; + cursor: pointer; +} +.admin-sidebar .sub-item:hover { background: var(--row-hover); } +.admin-sidebar .sub-item.active { + background: rgba(43, 108, 176, 0.1); + color: var(--accent); + font-weight: 600; +} +.admin-content { flex: 1; min-width: 0; padding: 1.25rem; } +@media (max-width: 700px) { + .admin-sidebar { width: 160px; padding: 0.75rem 0.4rem; } + .admin-content { padding: 0.75rem; } +} + +/* Small fixed-position dropdown menu, e.g. the "..." menu on a card. */ +.menu { + position: fixed; + background: #fff; + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + z-index: 100; + min-width: 150px; + padding: 0.25rem; +} +.menu button { + display: block; + width: 100%; + text-align: left; + border: none; + border-radius: var(--radius); + padding: 0.5rem 0.6rem; + background: transparent; +} +.menu button:hover { background: var(--row-hover); } +.menu button.danger { color: var(--danger); } + +.card.clickable { + cursor: pointer; + transition: box-shadow 0.1s ease, border-color 0.1s ease; +} +.card.clickable:hover { + border-color: var(--accent); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); +}