Compare commits

...
5 Commits
Author SHA1 Message Date
CodingPhoenix ddaeccbea9 Nudge the WS status dot up 1px to line up with its label
top: 1px pushed the dot the wrong way; the label's glyphs sit slightly
above the flex centre line, so the dot needs to move up, not down, to
match.
2026-09-06 06:26:18 +02:00
CodingPhoenix 93a81fd82c Move competition back button onto the page with a real icon
The back button moves out of the topbar to the top-left of the page
above the competition name, and now renders an inline SVG arrow
(iconArrowLeft) instead of the "←" character.
2026-09-06 06:23:54 +02:00
CodingPhoenix 357888e310 Fix WebSocket status label not updating and move it into the topbar
The onopen/onclose handlers only toggled the CSS class on the status
dot, never the separate text span, so it stayed "offline" after a
reconnect. Replace the querySelector-based toggling with an
updateWsIndicator() helper that updates both the dot and its label
from stored element refs.

The indicator now lives in the topbar (via renderTopbar's extra slot)
instead of next to the competition name, with its own .ws-indicator
flex wrapper so the dot and label line up vertically.
2026-09-06 06:23:42 +02:00
CodingPhoenix ae035c3fd4 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.
2026-09-05 13:33:05 +02:00
CodingPhoenix e077703d5d Fix rules CSV parsing and add rule text editing API
Strip a leading UTF-8 BOM before parsing rule CSVs (it was causing the
header row to be misread as a rule) and skip rows that clearly failed
CSV parsing (e.g. a whole line landing in the rule_number field after
a spreadsheet app double-encoded a quoted cell) instead of silently
loading garbage.

Add PUT /api/rules/{lang}/{number} and GET /api/rules/numbers so rule
text, suggested penalty and escalation mode can be edited per language
from the admin UI, always writing the CSV back out with correct
quoting via encoding/csv. Also fixes two bugs found while wiring this
up: rulesDir() ignored the configured rules_dir (falling back to a
RULES_DIR env var that's never set), and the CORS middleware didn't
allow PUT.
2026-09-05 13:32:46 +02:00
11 changed files with 843 additions and 197 deletions
+3 -1
View File
@@ -29,6 +29,7 @@ type Config struct {
var corsOrigins []string var corsOrigins []string
var crossSiteCookies bool var crossSiteCookies bool
var backupDir string var backupDir string
var configuredRulesDir string
func defaultConfig() *Config { func defaultConfig() *Config {
return &Config{ return &Config{
@@ -128,6 +129,7 @@ func main() {
corsOrigins = cfg.CORSOrigins corsOrigins = cfg.CORSOrigins
crossSiteCookies = cfg.CrossSiteCookies crossSiteCookies = cfg.CrossSiteCookies
backupDir = cfg.BackupDir backupDir = cfg.BackupDir
configuredRulesDir = cfg.RulesDir
if err := openDB(cfg.DBPath); err != nil { if err := openDB(cfg.DBPath); err != nil {
log.Fatalf("db open: %v", err) log.Fatalf("db open: %v", err)
@@ -285,7 +287,7 @@ func withCORS(next http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin") w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
reqHeaders := r.Header.Get("Access-Control-Request-Headers") reqHeaders := r.Header.Get("Access-Control-Request-Headers")
if reqHeaders == "" { if reqHeaders == "" {
reqHeaders = "Content-Type" reqHeaders = "Content-Type"
+160 -13
View File
@@ -1,12 +1,17 @@
package main package main
import ( import (
"bufio"
"encoding/csv" "encoding/csv"
"encoding/json"
"log"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"sync" "sync"
"unicode/utf8"
) )
var ( var (
@@ -14,6 +19,72 @@ var (
rules = map[string]map[string]Rule{} rules = map[string]map[string]Rule{}
) )
var langNameRe = regexp.MustCompile(`^[a-z]{2,5}$`)
func rulesDir() string {
if configuredRulesDir != "" {
return configuredRulesDir
}
return "rules"
}
// readCSVFile reads a CSV file, transparently stripping a leading UTF-8 BOM
// (common when the file has been round-tripped through a spreadsheet app).
func readCSVFile(path string) ([][]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
br := bufio.NewReader(f)
if bom, err := br.Peek(3); err == nil && bom[0] == 0xEF && bom[1] == 0xBB && bom[2] == 0xBF {
br.Discard(3)
}
r := csv.NewReader(br)
r.FieldsPerRecord = -1
return r.ReadAll()
}
// writeCSVFile writes records back out with correct quoting, which is what
// prevents the "double-encoded row" corruption spreadsheet apps tend to cause.
func writeCSVFile(path string, records [][]string) error {
f, err := os.Create(path)
if err != nil {
return err
}
w := csv.NewWriter(f)
if err := w.WriteAll(records); err != nil {
f.Close()
return err
}
w.Flush()
if err := w.Error(); err != nil {
f.Close()
return err
}
return f.Close()
}
// upsertRuleRecord updates the row for `number` in-place, or appends a new
// row if it isn't present yet. records[0] is assumed to be the header.
func upsertRuleRecord(records [][]string, number, text, penalty, escalation string) [][]string {
for i, rec := range records {
if i == 0 || len(rec) < 1 {
continue
}
if strings.TrimSpace(rec[0]) != number {
continue
}
for len(rec) < 4 {
rec = append(rec, "")
}
rec[1], rec[2], rec[3] = text, penalty, escalation
records[i] = rec
return records
}
return append(records, []string{number, text, penalty, escalation})
}
func loadRules(dir string) error { func loadRules(dir string) error {
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
@@ -25,14 +96,7 @@ func loadRules(dir string) error {
continue continue
} }
lang := strings.TrimSuffix(e.Name(), ".csv") lang := strings.TrimSuffix(e.Name(), ".csv")
f, err := os.Open(filepath.Join(dir, e.Name())) records, err := readCSVFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
r := csv.NewReader(f)
r.FieldsPerRecord = -1
records, err := r.ReadAll()
f.Close()
if err != nil { if err != nil {
continue continue
} }
@@ -47,6 +111,10 @@ func loadRules(dir string) error {
continue continue
} }
} }
if strings.ContainsAny(rec[0], ",\"") || !utf8.ValidString(rec[0]) {
log.Printf("rules: skipping malformed row %d in %s: %q", i+1, e.Name(), rec[0])
continue
}
ru := Rule{ ru := Rule{
Number: strings.TrimSpace(rec[0]), Number: strings.TrimSpace(rec[0]),
Text: strings.TrimSpace(rec[1]), Text: strings.TrimSpace(rec[1]),
@@ -91,6 +159,8 @@ func getRules(lang string) []Rule {
func registerRuleRoutes(mux *http.ServeMux) { func registerRuleRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/rules", requireAuth(handleListRules)) mux.HandleFunc("GET /api/rules", requireAuth(handleListRules))
mux.HandleFunc("GET /api/rules/languages", requireAuth(handleListRuleLanguages)) mux.HandleFunc("GET /api/rules/languages", requireAuth(handleListRuleLanguages))
mux.HandleFunc("GET /api/rules/numbers", requireAuth(handleListRuleNumbers))
mux.HandleFunc("PUT /api/rules/{lang}/{number}", requireAdmin(handleUpdateRule))
mux.HandleFunc("POST /api/rules/reload", requireAdmin(handleReloadRules)) mux.HandleFunc("POST /api/rules/reload", requireAdmin(handleReloadRules))
} }
@@ -114,13 +184,90 @@ func handleListRules(w http.ResponseWriter, r *http.Request) {
} }
func handleReloadRules(w http.ResponseWriter, r *http.Request) { func handleReloadRules(w http.ResponseWriter, r *http.Request) {
dir := os.Getenv("RULES_DIR") if err := loadRules(rulesDir()); err != nil {
if dir == "" {
dir = "rules"
}
if err := loadRules(dir); err != nil {
writeError(w, http.StatusInternalServerError, "load_error") writeError(w, http.StatusInternalServerError, "load_error")
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// handleListRuleNumbers returns the union of rule numbers known across every
// loaded language, so the rules-editor UI can show a row even for a rule
// that a given language's file hasn't been given a translation for yet.
func handleListRuleNumbers(w http.ResponseWriter, r *http.Request) {
rulesMu.RLock()
seen := map[string]bool{}
out := make([]string, 0, 64)
for _, langMap := range rules {
for number := range langMap {
if !seen[number] {
seen[number] = true
out = append(out, number)
}
}
}
rulesMu.RUnlock()
writeJSON(w, http.StatusOK, out)
}
func handleUpdateRule(w http.ResponseWriter, r *http.Request) {
lang := r.PathValue("lang")
number := strings.TrimSpace(r.PathValue("number"))
if !langNameRe.MatchString(lang) {
writeError(w, http.StatusBadRequest, "invalid_language")
return
}
path := filepath.Join(rulesDir(), lang+".csv")
if _, err := os.Stat(path); err != nil {
writeError(w, http.StatusNotFound, "unknown_language")
return
}
if number == "" {
writeError(w, http.StatusBadRequest, "invalid_number")
return
}
var req struct {
Text string `json:"text"`
SuggestedPenalty string `json:"suggested_penalty"`
EscalationMode string `json:"escalation_mode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
req.Text = strings.TrimSpace(req.Text)
if req.Text == "" {
writeError(w, http.StatusBadRequest, "missing_text")
return
}
if req.EscalationMode == "" {
req.EscalationMode = "same"
}
records, err := readCSVFile(path)
if err != nil {
writeError(w, http.StatusInternalServerError, "read_error")
return
}
if len(records) == 0 {
records = [][]string{{"rule_number", "rule_text", "suggested_penalty", "escalation_mode"}}
}
records = upsertRuleRecord(records, number, req.Text, req.SuggestedPenalty, req.EscalationMode)
if err := writeCSVFile(path, records); err != nil {
writeError(w, http.StatusInternalServerError, "write_error")
return
}
if err := loadRules(rulesDir()); err != nil {
writeError(w, http.StatusInternalServerError, "reload_error")
return
}
rulesMu.RLock()
ru, ok := rules[lang][number]
rulesMu.RUnlock()
if !ok {
writeError(w, http.StatusInternalServerError, "reload_mismatch")
return
}
writeJSON(w, http.StatusOK, ru)
}
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Penalty Tracker — System Admin</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app"></div>
<script src="/config.js"></script>
<script src="/i18n.js"></script>
<script src="/api.js"></script>
<script src="/common.js"></script>
<script src="/admin.js"></script>
</body>
</html>
+376
View File
@@ -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();
})();
+3
View File
@@ -81,6 +81,9 @@ const API = {
listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`), listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`),
listRuleLanguages: () => api("GET", "/api/rules/languages"), 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) { function openCompetitionWS(id, handlers) {
+75 -22
View File
@@ -37,6 +37,7 @@ const PAGES = {
competitions: "competitions.html", competitions: "competitions.html",
competition: "competition.html", competition: "competition.html",
forcePassword: "force-password.html", forcePassword: "force-password.html",
admin: "admin.html",
}; };
function navigate(page, params) { function navigate(page, params) {
@@ -84,40 +85,56 @@ async function bootstrapAuth(options) {
// Standard topbar shown on authenticated pages. // Standard topbar shown on authenticated pages.
function renderTopbar(user, opts) { function renderTopbar(user, opts) {
opts = 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 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); user.display_name || user.username);
return el("div", { class: "topbar" }, return el("div", { class: "topbar" },
brand, el("div", { class: "row" }, brand, systemAdminBtn),
el("div", { class: "nav" }, el("div", { class: "nav" },
opts.extra || null, opts.extra || null,
profileBtn, profileBtn,
langSelect,
logoutBtn,
) )
); );
} }
// Self-contained profile modal: language and password only. Username/display // Small popup anchored below the profile button: display name, a quick
// name are read-only here (only system admin can change them). // 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) { function openProfileModal(user) {
const backdrop = el("div", { class: "modal-backdrop", const backdrop = el("div", { class: "modal-backdrop",
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
@@ -177,3 +194,39 @@ function queryParams() {
return out; 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));
}
});
}
+26 -9
View File
@@ -1313,14 +1313,35 @@
// ---- Main render ------------------------------------------------------- // ---- Main render -------------------------------------------------------
function iconArrowLeft() {
const s = el("span", { class: "icon" });
s.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>';
return s;
}
function wsIndicator() {
state.wsDot = el("span", { class: "connection-status " + (state.wsOnline ? "online" : "offline") });
state.wsText = el("span", { class: "muted" }, state.wsOnline ? t("online") : t("offline"));
return el("span", { class: "ws-indicator" }, state.wsDot, state.wsText);
}
function updateWsIndicator() {
if (state.wsDot) {
state.wsDot.classList.toggle("online", state.wsOnline);
state.wsDot.classList.toggle("offline", !state.wsOnline);
}
if (state.wsText) state.wsText.textContent = state.wsOnline ? t("online") : t("offline");
}
function render() { function render() {
clearNode(root); clearNode(root);
const backBtn = el("button", { class: "ghost", const backBtn = el("button", { class: "ghost btn-back",
onclick: () => { if (state.ws) { state.ws.close(); state.ws = null; } navigate("competitions"); } }, onclick: () => { if (state.ws) { state.ws.close(); state.ws = null; } navigate("competitions"); } },
"← " + t("back")); iconArrowLeft(), t("back"));
root.appendChild(renderTopbar(user, { extra: backBtn })); root.appendChild(renderTopbar(user, { extra: wsIndicator() }));
const container = el("div", { class: "container" }); const container = el("div", { class: "container" });
container.appendChild(backBtn);
container.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } }, container.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } },
el("h2", { style: { margin: 0 } }, el("h2", { style: { margin: 0 } },
state.competition.name, state.competition.name,
@@ -1329,10 +1350,6 @@
isClosed() ? " " : null, isClosed() ? " " : null,
isClosed() ? el("span", { class: "badge warn" }, t("closed")) : null, isClosed() ? el("span", { class: "badge warn" }, t("closed")) : null,
), ),
el("div", { class: "row" },
el("span", { class: "connection-status " + (state.wsOnline ? "online" : "offline") }),
el("span", { class: "muted" }, state.wsOnline ? t("online") : t("offline")),
),
)); ));
const tabs = el("div", { class: "tabs" }); const tabs = el("div", { class: "tabs" });
@@ -1393,8 +1410,8 @@
await loadAll(); await loadAll();
state.ws = openCompetitionWS(competitionId, { state.ws = openCompetitionWS(competitionId, {
onopen: () => { state.wsOnline = true; const e = document.querySelector(".connection-status"); if (e) { e.classList.add("online"); e.classList.remove("offline"); } }, onopen: () => { state.wsOnline = true; updateWsIndicator(); },
onclose: () => { state.wsOnline = false; const e = document.querySelector(".connection-status"); if (e) { e.classList.remove("online"); e.classList.add("offline"); } }, onclose: () => { state.wsOnline = false; updateWsIndicator(); },
onmessage: handleWSMessage, onmessage: handleWSMessage,
}); });
render(); render();
+61 -161
View File
@@ -3,14 +3,11 @@
const user = await bootstrapAuth({ requireAuth: true, forbidIfMustChange: true }); const user = await bootstrapAuth({ requireAuth: true, forbidIfMustChange: true });
if (!user) return; if (!user) return;
const state = { competitions: [], users: [] }; const state = { competitions: [], filter: "all" };
async function loadCompetitions() { async function loadCompetitions() {
state.competitions = await API.listCompetitions(); state.competitions = await API.listCompetitions();
} }
async function loadUsers() {
if (user.is_system_admin) state.users = await API.listUsers();
}
async function openCompetitionModal() { async function openCompetitionModal() {
const backdrop = el("div", { class: "modal-backdrop", const backdrop = el("div", { class: "modal-backdrop",
@@ -53,178 +50,81 @@
document.body.appendChild(backdrop); document.body.appendChild(backdrop);
} }
function openUserModal() { function openCardMenu(anchorEl, c) {
const backdrop = el("div", { class: "modal-backdrop", openInlineMenu(anchorEl, [
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); { label: t("delete"), danger: true, onClick: async () => {
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 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 render() {
clearNode(root);
root.appendChild(renderTopbar(user));
const container = el("div", { class: "container" });
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")),
));
if (state.competitions.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" } },
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; if (!confirm(t("confirm_delete_competition_named", { name: c.name }))) return;
try { try {
await API.deleteCompetition(c.id); await API.deleteCompetition(c.id);
await loadCompetitions(); await loadCompetitions();
render(); render();
} catch (e) { alert((e.data && e.data.error) || e.message); } } catch (e) { alert((e.data && e.data.error) || e.message); }
} }, t("delete")), } },
]);
}
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")),
el("div", { class: "row" },
filterSelect,
user.is_system_admin && el("button", { class: "primary", onclick: openCompetitionModal }, t("new_competition")),
),
));
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 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,
), ),
)); ));
} }
container.appendChild(grid); container.appendChild(grid);
} }
if (user.is_system_admin) {
container.appendChild(renderUsersAdmin());
}
root.appendChild(container); root.appendChild(container);
} }
await loadCompetitions(); await loadCompetitions();
await loadUsers();
render(); render();
})(); })();
+12 -1
View File
@@ -145,5 +145,16 @@
"competition_closed": "Wettbewerb ist beendet", "competition_closed": "Wettbewerb ist beendet",
"upload_file": "Datei hochladen", "upload_file": "Datei hochladen",
"csv_separator_hint": "Komma (,) und Semikolon (;) werden automatisch erkannt.", "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"
} }
+12 -1
View File
@@ -145,5 +145,16 @@
"competition_closed": "Competition is closed", "competition_closed": "Competition is closed",
"upload_file": "Upload file", "upload_file": "Upload file",
"csv_separator_hint": "Comma (,) and semicolon (;) are detected automatically.", "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"
} }
+110 -1
View File
@@ -152,6 +152,7 @@ th {
background: #fafafa; background: #fafafa;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 2;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
white-space: nowrap; white-space: nowrap;
@@ -386,11 +387,33 @@ tbody tr.transferred { background: #f3f6fb; }
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: #d1d5db; background: #d1d5db;
margin-right: 0.5rem; flex: none;
} }
.connection-status.online { background: #10b981; } .connection-status.online { background: #10b981; }
.connection-status.offline { background: #ef4444; } .connection-status.offline { background: #ef4444; }
.ws-indicator {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
line-height: 1;
}
/* Nudge the dot up ~1px: the label (Online/Offline) has unused descender
space, so its glyphs sit above the flex centre line while the dot is
dead-centred — lifting the dot lines the two up optically. */
.ws-indicator .connection-status { position: relative; top: -1px; }
.btn-back {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.75rem;
padding-left: 0.3rem;
}
.icon { display: inline-flex; }
.icon svg { display: block; }
textarea { resize: vertical; min-height: 60px; } textarea { resize: vertical; min-height: 60px; }
@media (max-width: 700px) { @media (max-width: 700px) {
@@ -450,3 +473,89 @@ button[disabled] { opacity: 0.55; cursor: not-allowed; }
.rules-table th.col-escalation, .rules-table th.col-escalation,
.rules-table td.col-escalation { width: 10rem; } .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);
}