Compare commits
5
Commits
24a4eee4a6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddaeccbea9 | ||
|
|
93a81fd82c | ||
|
|
357888e310 | ||
|
|
ae035c3fd4 | ||
|
|
e077703d5d |
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
+49
-149
@@ -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",
|
if (!confirm(t("confirm_delete_competition_named", { name: c.name }))) return;
|
||||||
oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } });
|
try {
|
||||||
const password = el("input", { type: "password" });
|
await API.deleteCompetition(c.id);
|
||||||
const displayName = el("input", { type: "text" });
|
await loadCompetitions();
|
||||||
const langSelect = el("select", null,
|
render();
|
||||||
...I18N_AVAILABLE.map((l) => el("option", { value: l }, I18N_NAMES[l]))
|
} catch (e) { alert((e.data && e.data.error) || e.message); }
|
||||||
);
|
} },
|
||||||
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() {
|
function matchesFilter(c) {
|
||||||
const card = el("div", { class: "card" });
|
if (state.filter === "open") return !c.closed;
|
||||||
card.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } },
|
if (state.filter === "closed") return !!c.closed;
|
||||||
el("h2", { style: { margin: 0 } }, t("users")),
|
return true;
|
||||||
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() {
|
function render() {
|
||||||
clearNode(root);
|
clearNode(root);
|
||||||
root.appendChild(renderTopbar(user));
|
root.appendChild(renderTopbar(user));
|
||||||
const container = el("div", { class: "container" });
|
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" } },
|
container.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.75rem" } },
|
||||||
el("h2", { style: { margin: 0 } }, t("competitions")),
|
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")));
|
container.appendChild(el("div", { class: "muted" }, t("no_competitions")));
|
||||||
} else {
|
} else {
|
||||||
const grid = el("div", { class: "grid" });
|
const grid = el("div", { class: "grid" });
|
||||||
for (const c of state.competitions) {
|
for (const c of filtered) {
|
||||||
grid.appendChild(el("div", { class: "card" },
|
const menuBtn = user.is_system_admin
|
||||||
el("h2", null, c.name),
|
? el("button", { class: "ghost action-btn", title: t("actions"), onclick: (e) => {
|
||||||
el("div", { class: "muted", style: { marginBottom: "0.5rem" } },
|
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)),
|
el("span", { class: "badge accent" }, t(c.role)),
|
||||||
c.closed ? " " : null,
|
c.closed ? " " : null,
|
||||||
c.closed ? el("span", { class: "badge warn" }, t("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);
|
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
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user