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