From 802906f9d483c6ecc8cc841e327dfd04ec541265 Mon Sep 17 00:00:00 2001 From: Jan Meinl Date: Sat, 16 May 2026 20:39:27 +0200 Subject: [PATCH] Add basic web app and API integration --- .gitignore | 4 + .idea/.gitignore | 8 + .idea/PenaltyTracker.iml | 9 + .idea/csv-editor.xml | 65 +++ .idea/go.imports.xml | 10 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + README.md | 84 +++ auth.go | 256 +++++++++ competitions.go | 248 ++++++++ db.go | 92 +++ go.mod | 25 + go.sum | 34 ++ main.go | 213 +++++++ models.go | 60 ++ penalties.go | 293 ++++++++++ pilots.go | 210 +++++++ rules.go | 115 ++++ rules/de.csv | 44 ++ rules/en.csv | 44 ++ rules/es.csv | 44 ++ rules/fr.csv | 44 ++ rules/pl.csv | 44 ++ rules/pt.csv | 44 ++ rules/ru.csv | 44 ++ users.go | 141 +++++ web/api.js | 96 ++++ web/app.js | 1163 ++++++++++++++++++++++++++++++++++++++ web/config.js | 24 + web/i18n.js | 323 +++++++++++ web/index.html | 16 + web/style.css | 399 +++++++++++++ ws.go | 130 +++++ 33 files changed, 4340 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/PenaltyTracker.iml create mode 100644 .idea/csv-editor.xml create mode 100644 .idea/go.imports.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 README.md create mode 100644 auth.go create mode 100644 competitions.go create mode 100644 db.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 models.go create mode 100644 penalties.go create mode 100644 pilots.go create mode 100644 rules.go create mode 100644 rules/de.csv create mode 100644 rules/en.csv create mode 100644 rules/es.csv create mode 100644 rules/fr.csv create mode 100644 rules/pl.csv create mode 100644 rules/pt.csv create mode 100644 rules/ru.csv create mode 100644 users.go create mode 100644 web/api.js create mode 100644 web/app.js create mode 100644 web/config.js create mode 100644 web/i18n.js create mode 100644 web/index.html create mode 100644 web/style.css create mode 100644 ws.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0978c43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/penalty-tracker +/penaltytracker.db +/penaltytracker.db-shm +/penaltytracker.db-wal diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..9a897b6 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/PenaltyTracker.iml b/.idea/PenaltyTracker.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/PenaltyTracker.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/csv-editor.xml b/.idea/csv-editor.xml new file mode 100644 index 0000000..e34998d --- /dev/null +++ b/.idea/csv-editor.xml @@ -0,0 +1,65 @@ + + + + + + \ No newline at end of file diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..fbfed63 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4891dd1 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# Penalty Tracker + +A small, self-contained system for tracking competition penalties (e.g. for hot-air balloon competitions). + +## Features + +- **System Admin** can create competitions and manage all users. +- **Chief Scorer** (per competition) manages pilots, members, settings, and can export penalties as CSV. Chief Scorers can promote others to Chief Scorer or Scorer. +- **Scorer** (per competition) adds penalties. Scorers can only edit/delete their own entries unless the competition setting "allow any scorer to edit" is on. +- Pilots can be added manually or imported from CSV (`number,last_name,first_name,country,balloon_id`). +- Rule catalogues per language are loaded from `rules/*.csv`. The penalty form lets you search rules by number or text and shows the suggested penalty plus repeat-behaviour (same / doubled / escalates). +- All open clients receive **live updates** for a competition via WebSocket — works for 30+ concurrent users on a single SQLite process. +- The penalty table is **client-side sortable** by every column and updates **in-place without flicker** when new rows arrive. +- Multi-lingual UI: English, German, Polish, Russian, French, Spanish, Portuguese (set per user in user settings). +- Clean black / white design with `#2b6cb0` accent, max border-radius `0.375rem`. + +## Build & run + +```bash +go build -o penaltytracker . +./penaltytracker +``` + +Then open . + +**Default login (created automatically on first start):** + +``` +username: admin +password: admin +``` + +Change the password immediately under the user menu. + +## Configuration (env vars) + +| Variable | Default | Description | +|-----------------|----------------------|--------------------------------------------------------------------------| +| `ADDR` | `:8080` | HTTP listen address | +| `DB_PATH` | `penaltytracker.db` | SQLite database path | +| `RULES_DIR` | `rules` | Directory containing per-language rule CSV files | +| `WEB_DIR` | `web` | Directory containing the frontend static files | +| `CORS_ORIGINS` | *(empty)* | Comma-separated allowed frontend origins (e.g. `http://127.0.0.1:36823,https://app.example.com`). When set, CORS headers are added. Use `*` to allow any origin. | +| `COOKIE_CROSS_SITE` | *(empty)* | Set to `true` only when the frontend runs on a different *site* (different registrable domain) than the API and you need cross-site cookies. Switches the session cookie to `SameSite=None; Secure` — requires HTTPS on both sides. Leave unset for same-host different-port setups (e.g. `127.0.0.1:36823 → 127.0.0.1:8080`); `SameSite=Lax` already works there. | + +### Frontend backend URL + +If you host the frontend on a different origin than the API, open the login screen and fill in **Backend URL** (e.g. `http://192.168.0.10:8080` or `https://api.example.com`). It is stored in `localStorage` (`api_base`). Leave it empty to use the same origin as the page. You can also pre-set it by editing `web/config.js` (`DEFAULT_API_BASE`). + +Cross-origin cookies require **HTTPS on both sides** (so the `Secure` cookie flag is accepted). For LAN testing on plain HTTP, host the frontend and backend on the same origin (the default). + +## Rules CSV format + +`rules/.csv` — one file per language code (`en`, `de`, `pl`, `ru`, `fr`, `es`, `pt`). + +```csv +rule_number,rule_text,suggested_penalty,escalation_mode +R1.1,Late at briefing,Warning,escalate:Warning|50 CP|100 CP|DSQ +R3.1,Unsafe flying,200 CP,doubled +R4.2,Incorrect declaration,No Result,same +``` + +`escalation_mode`: +- `same` — penalty stays the same on repeat. +- `doubled` — penalty doubles each time. +- `escalate:tier1|tier2|tier3|...` — penalty climbs through the listed tiers. + +To reload rules without restart, POST `/api/rules/reload` as a system admin. + +## Pilot import CSV format + +```csv +number,last_name,first_name,country,balloon_id +1,Doe,John,DE,D-OABC +2,Müller,Anna,DE, +``` + +Header row is optional and auto-detected. + +## Notes + +- Storage is a single SQLite file in WAL mode — easy to back up by copying the `.db` file (along with `-wal` / `-shm` if present) while the server is briefly stopped. +- The frontend is pure HTML/CSS/JS, no build step. +- The Go binary embeds nothing — it serves `./web` and `./rules` from the working directory. diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..b76539b --- /dev/null +++ b/auth.go @@ -0,0 +1,256 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type ctxKey string + +const userCtxKey ctxKey = "user" + +const sessionCookie = "pt_session" +const sessionDuration = 24 * time.Hour * 14 + +func registerAuthRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/login", handleLogin) + mux.HandleFunc("POST /api/logout", handleLogout) + mux.HandleFunc("GET /api/me", requireAuth(handleMe)) + mux.HandleFunc("PATCH /api/me", requireAuth(handleUpdateMe)) +} + +func ensureDefaultAdmin() error { + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil { + return err + } + if count > 0 { + return nil + } + hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost) + if err != nil { + return err + } + _, err = db.Exec( + "INSERT INTO users(username,password_hash,display_name,language,is_system_admin) VALUES(?,?,?,?,1)", + "admin", string(hash), "System Admin", "en", + ) + return err +} + +func newToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func handleLogin(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + req.Username = strings.TrimSpace(req.Username) + if req.Username == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "missing_credentials") + return + } + var id int64 + var hash string + err := db.QueryRow("SELECT id,password_hash FROM users WHERE username=?", req.Username).Scan(&id, &hash) + if err != nil { + writeError(w, http.StatusUnauthorized, "invalid_credentials") + return + } + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil { + writeError(w, http.StatusUnauthorized, "invalid_credentials") + return + } + token, err := newToken() + if err != nil { + writeError(w, http.StatusInternalServerError, "token_error") + return + } + expires := time.Now().Add(sessionDuration) + if _, err := db.Exec("INSERT INTO sessions(token,user_id,expires_at) VALUES(?,?,?)", token, id, expires.Format(time.RFC3339)); err != nil { + writeError(w, http.StatusInternalServerError, "session_error") + return + } + cookie := &http.Cookie{ + Name: sessionCookie, + Value: token, + Path: "/", + Expires: expires, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } + if crossSiteCookies { + cookie.SameSite = http.SameSiteNoneMode + cookie.Secure = true + } + http.SetCookie(w, cookie) + user, _ := loadUser(id) + writeJSON(w, http.StatusOK, user) +} + +func handleLogout(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(sessionCookie) + if err == nil { + db.Exec("DELETE FROM sessions WHERE token=?", c.Value) + } + clear := &http.Cookie{ + Name: sessionCookie, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } + if crossSiteCookies { + clear.SameSite = http.SameSiteNoneMode + clear.Secure = true + } + http.SetCookie(w, clear) + w.WriteHeader(http.StatusNoContent) +} + +func handleMe(w http.ResponseWriter, r *http.Request) { + u := userFromCtx(r) + writeJSON(w, http.StatusOK, u) +} + +func handleUpdateMe(w http.ResponseWriter, r *http.Request) { + u := userFromCtx(r) + var req struct { + Username *string `json:"username"` + Language *string `json:"language"` + DisplayName *string `json:"display_name"` + Password *string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + if req.Username != nil { + newName := strings.TrimSpace(*req.Username) + if newName == "" { + writeError(w, http.StatusBadRequest, "missing_username") + return + } + if _, err := db.Exec("UPDATE users SET username=? WHERE id=?", newName, u.ID); err != nil { + writeError(w, http.StatusConflict, "username_taken") + return + } + } + if req.Language != nil { + if _, err := db.Exec("UPDATE users SET language=? WHERE id=?", *req.Language, u.ID); err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + } + if req.DisplayName != nil { + if _, err := db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, u.ID); err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + } + if req.Password != nil && *req.Password != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + if err != nil { + writeError(w, http.StatusInternalServerError, "hash_error") + return + } + if _, err := db.Exec("UPDATE users SET password_hash=? WHERE id=?", string(hash), u.ID); err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + } + user, _ := loadUser(u.ID) + writeJSON(w, http.StatusOK, user) +} + +func loadUser(id int64) (*User, error) { + u := &User{} + var admin int + err := db.QueryRow("SELECT id,username,display_name,language,is_system_admin FROM users WHERE id=?", id). + Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin) + if err != nil { + return nil, err + } + u.IsSystemAdmin = admin == 1 + return u, nil +} + +func authUser(r *http.Request) (*User, error) { + c, err := r.Cookie(sessionCookie) + if err != nil { + return nil, errors.New("no_session") + } + var userID int64 + var expiresAt string + err = db.QueryRow("SELECT user_id, expires_at FROM sessions WHERE token=?", c.Value).Scan(&userID, &expiresAt) + if err != nil { + return nil, errors.New("invalid_session") + } + if t, err := time.Parse(time.RFC3339, expiresAt); err == nil && time.Now().After(t) { + db.Exec("DELETE FROM sessions WHERE token=?", c.Value) + return nil, errors.New("expired") + } + return loadUser(userID) +} + +func requireAuth(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u, err := authUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + ctx := context.WithValue(r.Context(), userCtxKey, u) + h.ServeHTTP(w, r.WithContext(ctx)) + } +} + +func requireAdmin(h http.HandlerFunc) http.HandlerFunc { + return requireAuth(func(w http.ResponseWriter, r *http.Request) { + u := userFromCtx(r) + if !u.IsSystemAdmin { + writeError(w, http.StatusForbidden, "forbidden") + return + } + h.ServeHTTP(w, r) + }) +} + +func userFromCtx(r *http.Request) *User { + v := r.Context().Value(userCtxKey) + if v == nil { + return nil + } + return v.(*User) +} + +func writeJSON(w http.ResponseWriter, code int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if body != nil { + _ = json.NewEncoder(w).Encode(body) + } +} + +func writeError(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"error": msg}) +} diff --git a/competitions.go b/competitions.go new file mode 100644 index 0000000..2c995f5 --- /dev/null +++ b/competitions.go @@ -0,0 +1,248 @@ +package main + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" +) + +func registerCompetitionRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/competitions", requireAuth(handleListCompetitions)) + mux.HandleFunc("POST /api/competitions", requireAdmin(handleCreateCompetition)) + mux.HandleFunc("GET /api/competitions/{id}", requireAuth(handleGetCompetition)) + mux.HandleFunc("PATCH /api/competitions/{id}", requireAuth(handleUpdateCompetition)) + mux.HandleFunc("DELETE /api/competitions/{id}", requireAdmin(handleDeleteCompetition)) + mux.HandleFunc("GET /api/competitions/{id}/members", requireAuth(handleListMembers)) + mux.HandleFunc("POST /api/competitions/{id}/members", requireAuth(handleAddMember)) + mux.HandleFunc("DELETE /api/competitions/{id}/members/{uid}", requireAuth(handleRemoveMember)) +} + +func userRole(competitionID, userID int64) (string, error) { + var role string + err := db.QueryRow("SELECT role FROM competition_users WHERE competition_id=? AND user_id=?", competitionID, userID).Scan(&role) + if err == sql.ErrNoRows { + return "", nil + } + return role, err +} + +func canAccessCompetition(u *User, competitionID int64) (string, bool) { + if u.IsSystemAdmin { + return "system_admin", true + } + role, err := userRole(competitionID, u.ID) + if err != nil || role == "" { + return "", false + } + return role, true +} + +func handleListCompetitions(w http.ResponseWriter, r *http.Request) { + u := userFromCtx(r) + var rows *sql.Rows + var err error + if u.IsSystemAdmin { + rows, err = db.Query("SELECT id,name,allow_any_scorer_edit,created_at FROM competitions ORDER BY created_at DESC") + } else { + rows, err = db.Query(`SELECT c.id,c.name,c.allow_any_scorer_edit,c.created_at,cu.role + FROM competitions c JOIN competition_users cu ON cu.competition_id=c.id + WHERE cu.user_id=? ORDER BY c.created_at DESC`, u.ID) + } + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + defer rows.Close() + out := []Competition{} + for rows.Next() { + var c Competition + var allow int + if u.IsSystemAdmin { + rows.Scan(&c.ID, &c.Name, &allow, &c.CreatedAt) + c.Role = "system_admin" + } else { + rows.Scan(&c.ID, &c.Name, &allow, &c.CreatedAt, &c.Role) + } + c.AllowAnyScorerEdit = allow == 1 + out = append(out, c) + } + writeJSON(w, http.StatusOK, out) +} + +func handleCreateCompetition(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + AllowAnyScorerEdit bool `json:"allow_any_scorer_edit"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "missing_name") + return + } + allow := 0 + if req.AllowAnyScorerEdit { + allow = 1 + } + res, err := db.Exec("INSERT INTO competitions(name,allow_any_scorer_edit) VALUES(?,?)", req.Name, allow) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + id, _ := res.LastInsertId() + c := Competition{ID: id, Name: req.Name, AllowAnyScorerEdit: req.AllowAnyScorerEdit} + writeJSON(w, http.StatusCreated, c) +} + +func handleGetCompetition(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + role, ok := canAccessCompetition(u, id) + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var c Competition + var allow int + err = db.QueryRow("SELECT id,name,allow_any_scorer_edit,created_at FROM competitions WHERE id=?", id). + Scan(&c.ID, &c.Name, &allow, &c.CreatedAt) + if err != nil { + writeError(w, http.StatusNotFound, "not_found") + return + } + c.AllowAnyScorerEdit = allow == 1 + c.Role = role + writeJSON(w, http.StatusOK, c) +} + +func handleUpdateCompetition(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + role, ok := canAccessCompetition(u, id) + if !ok || (role != "system_admin" && role != "chief_scorer") { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var req struct { + Name *string `json:"name"` + AllowAnyScorerEdit *bool `json:"allow_any_scorer_edit"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + if req.Name != nil { + db.Exec("UPDATE competitions SET name=? WHERE id=?", *req.Name, id) + } + if req.AllowAnyScorerEdit != nil { + v := 0 + if *req.AllowAnyScorerEdit { + v = 1 + } + db.Exec("UPDATE competitions SET allow_any_scorer_edit=? WHERE id=?", v, id) + } + hub.broadcast(id, "competition_updated", nil) + w.WriteHeader(http.StatusNoContent) +} + +func handleDeleteCompetition(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + db.Exec("DELETE FROM competitions WHERE id=?", id) + w.WriteHeader(http.StatusNoContent) +} + +func handleListMembers(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + if _, ok := canAccessCompetition(u, id); !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } + rows, err := db.Query(`SELECT u.id,u.username,u.display_name,cu.role + FROM competition_users cu JOIN users u ON u.id=cu.user_id + WHERE cu.competition_id=? ORDER BY cu.role,u.username`, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + defer rows.Close() + out := []CompetitionUser{} + for rows.Next() { + var m CompetitionUser + rows.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.Role) + out = append(out, m) + } + writeJSON(w, http.StatusOK, out) +} + +func handleAddMember(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + role, ok := canAccessCompetition(u, id) + if !ok || (role != "system_admin" && role != "chief_scorer") { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var req struct { + UserID int64 `json:"user_id"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + if req.Role != "chief_scorer" && req.Role != "scorer" { + writeError(w, http.StatusBadRequest, "invalid_role") + return + } + _, err = db.Exec("INSERT OR REPLACE INTO competition_users(competition_id,user_id,role) VALUES(?,?,?)", id, req.UserID, req.Role) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func handleRemoveMember(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + uid, err := strconv.ParseInt(r.PathValue("uid"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + role, ok := canAccessCompetition(u, id) + if !ok || (role != "system_admin" && role != "chief_scorer") { + writeError(w, http.StatusForbidden, "forbidden") + return + } + db.Exec("DELETE FROM competition_users WHERE competition_id=? AND user_id=?", id, uid) + w.WriteHeader(http.StatusNoContent) +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..332d67b --- /dev/null +++ b/db.go @@ -0,0 +1,92 @@ +package main + +import ( + "database/sql" + + _ "modernc.org/sqlite" +) + +var db *sql.DB + +func openDB(path string) error { + d, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)") + if err != nil { + return err + } + d.SetMaxOpenConns(1) + if err := d.Ping(); err != nil { + return err + } + db = d + return nil +} + +func migrate() error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT 'en', + is_system_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + `CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS competitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + allow_any_scorer_edit INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + `CREATE TABLE IF NOT EXISTS competition_users ( + competition_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + PRIMARY KEY (competition_id, user_id), + FOREIGN KEY(competition_id) REFERENCES competitions(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS pilots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + competition_id INTEGER NOT NULL, + number TEXT NOT NULL, + last_name TEXT NOT NULL, + first_name TEXT NOT NULL, + country TEXT NOT NULL DEFAULT '', + balloon_id TEXT NOT NULL DEFAULT '', + UNIQUE(competition_id, number), + FOREIGN KEY(competition_id) REFERENCES competitions(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS penalties ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + competition_id INTEGER NOT NULL, + flight TEXT NOT NULL DEFAULT '', + date TEXT NOT NULL DEFAULT '', + pilot_number TEXT NOT NULL DEFAULT '', + rule_number TEXT NOT NULL DEFAULT '', + task TEXT NOT NULL DEFAULT '', + penalties_text TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + created_by INTEGER NOT NULL, + transferred INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(competition_id) REFERENCES competitions(id) ON DELETE CASCADE, + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE RESTRICT + )`, + `CREATE INDEX IF NOT EXISTS idx_penalties_competition ON penalties(competition_id)`, + `CREATE INDEX IF NOT EXISTS idx_pilots_competition ON pilots(competition_id)`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + return err + } + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a710bbb --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module penaltytracker + +go 1.25.0 + +require ( + github.com/gorilla/websocket v1.5.3 + golang.org/x/crypto v0.51.0 + modernc.org/sqlite v1.50.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.44.0 // indirect + modernc.org/gc/v3 v3.1.3 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/strutil v1.2.1 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..90f0b62 --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= +modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..22af29a --- /dev/null +++ b/main.go @@ -0,0 +1,213 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" +) + +type Config struct { + Addr string `json:"addr"` + DBPath string `json:"db_path"` + RulesDir string `json:"rules_dir"` + CORSOrigins []string `json:"cors_origins"` + CrossSiteCookies bool `json:"cross_site_cookies"` +} + +var corsOrigins []string +var crossSiteCookies bool + +func defaultConfig() *Config { + return &Config{ + Addr: ":8080", + DBPath: "penaltytracker.db", + RulesDir: "rules", + CORSOrigins: []string{}, + CrossSiteCookies: false, + } +} + +func writeConfig(path string, cfg *Config) error { + if dir := filepath.Dir(path); dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func loadConfig(path string) (*Config, error) { + cfg := defaultConfig() + + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + if err := writeConfig(path, cfg); err != nil { + return nil, err + } + log.Printf("config file created at %s with defaults", path) + return cfg, nil + } + return nil, err + } + + if err := json.Unmarshal(data, cfg); err != nil { + return nil, err + } + if cfg.Addr == "" { + cfg.Addr = ":8080" + } + if cfg.DBPath == "" { + cfg.DBPath = "penaltytracker.db" + } + if cfg.RulesDir == "" { + cfg.RulesDir = "rules" + } + for i, o := range cfg.CORSOrigins { + cfg.CORSOrigins[i] = strings.TrimSpace(o) + } + return cfg, nil +} + +func ensureDir(dir string) error { + if dir == "" || dir == "." { + return nil + } + return os.MkdirAll(dir, 0o755) +} + +func ensureDirectories(cfg *Config) error { + if err := ensureDir(cfg.RulesDir); err != nil { + return err + } + if dbDir := filepath.Dir(cfg.DBPath); dbDir != "" { + if err := ensureDir(dbDir); err != nil { + return err + } + } + return nil +} + +func main() { + configPath := flag.String("config", "config.json", "path to config file") + flag.Parse() + + cfg, err := loadConfig(*configPath) + if err != nil { + log.Fatalf("config load: %v", err) + } + + if err := ensureDirectories(cfg); err != nil { + log.Fatalf("ensure directories: %v", err) + } + + corsOrigins = cfg.CORSOrigins + crossSiteCookies = cfg.CrossSiteCookies + + if err := openDB(cfg.DBPath); err != nil { + log.Fatalf("db open: %v", err) + } + if err := migrate(); err != nil { + log.Fatalf("migrate: %v", err) + } + if err := ensureDefaultAdmin(); err != nil { + log.Fatalf("default admin: %v", err) + } + if err := loadRules(cfg.RulesDir); err != nil { + log.Printf("rules load warning: %v", err) + } + + hub = newHub() + go hub.run() + + mux := http.NewServeMux() + registerAuthRoutes(mux) + registerUserRoutes(mux) + registerCompetitionRoutes(mux) + registerPilotRoutes(mux) + registerPenaltyRoutes(mux) + registerRuleRoutes(mux) + registerWSRoutes(mux) + + server := &http.Server{ + Addr: cfg.Addr, + Handler: withLog(withCORS(mux)), + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + log.Printf("listening on %s (cors_origins=%v)", cfg.Addr, corsOrigins) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %v", err) + } + }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = server.Shutdown(ctx) +} + +func withLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start)) + }) +} + +func originAllowed(origin string) bool { + if origin == "" { + return false + } + for _, o := range corsOrigins { + if o == "*" { + return true + } + if strings.EqualFold(o, origin) { + return true + } + } + return false +} + +func withCORS(next http.Handler) http.Handler { + if len(corsOrigins) == 0 { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if originAllowed(origin) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS") + reqHeaders := r.Header.Get("Access-Control-Request-Headers") + if reqHeaders == "" { + reqHeaders = "Content-Type" + } + w.Header().Set("Access-Control-Allow-Headers", reqHeaders) + w.Header().Set("Access-Control-Max-Age", "600") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/models.go b/models.go new file mode 100644 index 0000000..a3caac2 --- /dev/null +++ b/models.go @@ -0,0 +1,60 @@ +package main + +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Language string `json:"language"` + IsSystemAdmin bool `json:"is_system_admin"` +} + +type Competition struct { + ID int64 `json:"id"` + Name string `json:"name"` + AllowAnyScorerEdit bool `json:"allow_any_scorer_edit"` + CreatedAt string `json:"created_at"` + Role string `json:"role,omitempty"` +} + +type CompetitionUser struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Role string `json:"role"` +} + +type Pilot struct { + ID int64 `json:"id"` + CompetitionID int64 `json:"competition_id"` + Number string `json:"number"` + LastName string `json:"last_name"` + FirstName string `json:"first_name"` + Country string `json:"country"` + BalloonID string `json:"balloon_id"` +} + +type Penalty struct { + ID int64 `json:"id"` + CompetitionID int64 `json:"competition_id"` + Flight string `json:"flight"` + Date string `json:"date"` + PilotNumber string `json:"pilot_number"` + PilotName string `json:"pilot_name"` + RuleNumber string `json:"rule_number"` + Task string `json:"task"` + PenaltiesText string `json:"penalties_text"` + Description string `json:"description"` + CreatedBy int64 `json:"created_by"` + CreatedByName string `json:"created_by_name"` + Transferred bool `json:"transferred"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type Rule struct { + Number string `json:"number"` + Text string `json:"text"` + SuggestedPenalty string `json:"suggested_penalty"` + EscalationMode string `json:"escalation_mode"` + EscalationTiers []string `json:"escalation_tiers,omitempty"` +} diff --git a/penalties.go b/penalties.go new file mode 100644 index 0000000..d7c1bca --- /dev/null +++ b/penalties.go @@ -0,0 +1,293 @@ +package main + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" +) + +func registerPenaltyRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/competitions/{id}/penalties", requireAuth(handleListPenalties)) + mux.HandleFunc("POST /api/competitions/{id}/penalties", requireAuth(handleCreatePenalty)) + mux.HandleFunc("PATCH /api/competitions/{id}/penalties/{pid}", requireAuth(handleUpdatePenalty)) + mux.HandleFunc("DELETE /api/competitions/{id}/penalties/{pid}", requireAuth(handleDeletePenalty)) + mux.HandleFunc("GET /api/competitions/{id}/penalties.csv", requireAuth(handleExportPenalties)) +} + +func loadPenalty(id int64) (*Penalty, error) { + row := db.QueryRow(`SELECT p.id,p.competition_id,p.flight,p.date,p.pilot_number, + COALESCE(pl.last_name || ', ' || pl.first_name, ''), + p.rule_number,p.task,p.penalties_text,p.description,p.created_by, + COALESCE(u.display_name, u.username),p.transferred,p.created_at,p.updated_at + FROM penalties p + LEFT JOIN pilots pl ON pl.competition_id=p.competition_id AND pl.number=p.pilot_number + LEFT JOIN users u ON u.id=p.created_by + WHERE p.id=?`, id) + var pen Penalty + var transferred int + err := row.Scan(&pen.ID, &pen.CompetitionID, &pen.Flight, &pen.Date, &pen.PilotNumber, + &pen.PilotName, &pen.RuleNumber, &pen.Task, &pen.PenaltiesText, &pen.Description, + &pen.CreatedBy, &pen.CreatedByName, &transferred, &pen.CreatedAt, &pen.UpdatedAt) + if err != nil { + return nil, err + } + pen.Transferred = transferred == 1 + return &pen, nil +} + +func handleListPenalties(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + if _, ok := canAccessCompetition(u, id); !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } + rows, err := db.Query(`SELECT p.id,p.competition_id,p.flight,p.date,p.pilot_number, + COALESCE(pl.last_name || ', ' || pl.first_name, ''), + p.rule_number,p.task,p.penalties_text,p.description,p.created_by, + COALESCE(u.display_name, u.username),p.transferred,p.created_at,p.updated_at + FROM penalties p + LEFT JOIN pilots pl ON pl.competition_id=p.competition_id AND pl.number=p.pilot_number + LEFT JOIN users u ON u.id=p.created_by + WHERE p.competition_id=? ORDER BY p.id DESC`, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + defer rows.Close() + out := []Penalty{} + for rows.Next() { + var pen Penalty + var transferred int + rows.Scan(&pen.ID, &pen.CompetitionID, &pen.Flight, &pen.Date, &pen.PilotNumber, + &pen.PilotName, &pen.RuleNumber, &pen.Task, &pen.PenaltiesText, &pen.Description, + &pen.CreatedBy, &pen.CreatedByName, &transferred, &pen.CreatedAt, &pen.UpdatedAt) + pen.Transferred = transferred == 1 + out = append(out, pen) + } + writeJSON(w, http.StatusOK, out) +} + +func handleCreatePenalty(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + role, ok := canAccessCompetition(u, id) + if !ok || (role != "scorer" && role != "chief_scorer" && role != "system_admin") { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var pen Penalty + if err := json.NewDecoder(r.Body).Decode(&pen); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + transferred := 0 + if pen.Transferred { + transferred = 1 + } + res, err := db.Exec(`INSERT INTO penalties(competition_id,flight,date,pilot_number,rule_number,task,penalties_text,description,created_by,transferred) + VALUES(?,?,?,?,?,?,?,?,?,?)`, + id, pen.Flight, pen.Date, pen.PilotNumber, pen.RuleNumber, pen.Task, pen.PenaltiesText, pen.Description, u.ID, transferred) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + pid, _ := res.LastInsertId() + created, err := loadPenalty(pid) + if err != nil { + writeError(w, http.StatusInternalServerError, "load_error") + return + } + hub.broadcast(id, "penalty_created", created) + writeJSON(w, http.StatusCreated, created) +} + +func canEditPenalty(u *User, competitionID, createdBy int64) bool { + role, ok := canAccessCompetition(u, competitionID) + if !ok { + return false + } + if role == "system_admin" || role == "chief_scorer" { + return true + } + if role == "scorer" { + if u.ID == createdBy { + return true + } + var allow int + db.QueryRow("SELECT allow_any_scorer_edit FROM competitions WHERE id=?", competitionID).Scan(&allow) + return allow == 1 + } + return false +} + +func handleUpdatePenalty(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + var createdBy int64 + if err := db.QueryRow("SELECT created_by FROM penalties WHERE id=? AND competition_id=?", pid, id).Scan(&createdBy); err != nil { + writeError(w, http.StatusNotFound, "not_found") + return + } + if !canEditPenalty(u, id, createdBy) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var req struct { + Flight *string `json:"flight"` + Date *string `json:"date"` + PilotNumber *string `json:"pilot_number"` + RuleNumber *string `json:"rule_number"` + Task *string `json:"task"` + PenaltiesText *string `json:"penalties_text"` + Description *string `json:"description"` + Transferred *bool `json:"transferred"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + sets := []string{} + args := []any{} + if req.Flight != nil { + sets = append(sets, "flight=?") + args = append(args, *req.Flight) + } + if req.Date != nil { + sets = append(sets, "date=?") + args = append(args, *req.Date) + } + if req.PilotNumber != nil { + sets = append(sets, "pilot_number=?") + args = append(args, *req.PilotNumber) + } + if req.RuleNumber != nil { + sets = append(sets, "rule_number=?") + args = append(args, *req.RuleNumber) + } + if req.Task != nil { + sets = append(sets, "task=?") + args = append(args, *req.Task) + } + if req.PenaltiesText != nil { + sets = append(sets, "penalties_text=?") + args = append(args, *req.PenaltiesText) + } + if req.Description != nil { + sets = append(sets, "description=?") + args = append(args, *req.Description) + } + if req.Transferred != nil { + v := 0 + if *req.Transferred { + v = 1 + } + sets = append(sets, "transferred=?") + args = append(args, v) + } + if len(sets) > 0 { + sets = append(sets, "updated_at=datetime('now')") + args = append(args, pid) + query := "UPDATE penalties SET " + strings.Join(sets, ",") + " WHERE id=?" + if _, err := db.Exec(query, args...); err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + } + updated, err := loadPenalty(pid) + if err != nil { + writeError(w, http.StatusInternalServerError, "load_error") + return + } + hub.broadcast(id, "penalty_updated", updated) + writeJSON(w, http.StatusOK, updated) +} + +func handleDeletePenalty(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + var createdBy int64 + if err := db.QueryRow("SELECT created_by FROM penalties WHERE id=? AND competition_id=?", pid, id).Scan(&createdBy); err != nil { + writeError(w, http.StatusNotFound, "not_found") + return + } + if !canEditPenalty(u, id, createdBy) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + db.Exec("DELETE FROM penalties WHERE id=?", pid) + hub.broadcast(id, "penalty_deleted", map[string]int64{"id": pid}) + w.WriteHeader(http.StatusNoContent) +} + +func handleExportPenalties(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + role, ok := canAccessCompetition(u, id) + if !ok || (role != "chief_scorer" && role != "system_admin") { + writeError(w, http.StatusForbidden, "forbidden") + return + } + rows, err := db.Query(`SELECT p.id,p.flight,p.date,p.pilot_number, + COALESCE(pl.last_name || ', ' || pl.first_name, ''), + p.rule_number,p.task,p.penalties_text,p.description, + COALESCE(u.display_name, u.username),p.transferred,p.created_at + FROM penalties p + LEFT JOIN pilots pl ON pl.competition_id=p.competition_id AND pl.number=p.pilot_number + LEFT JOIN users u ON u.id=p.created_by + WHERE p.competition_id=? ORDER BY p.id`, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + defer rows.Close() + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"penalties_%d.csv\"", id)) + cw := csv.NewWriter(w) + cw.Write([]string{"id", "flight", "date", "pilot_number", "pilot_name", "rule_number", "task", "penalties", "description", "created_by", "transferred", "created_at"}) + for rows.Next() { + var idv int64 + var flight, date, pnum, pname, rnum, task, pens, desc, creator, createdAt string + var transferred int + rows.Scan(&idv, &flight, &date, &pnum, &pname, &rnum, &task, &pens, &desc, &creator, &transferred, &createdAt) + t := "0" + if transferred == 1 { + t = "1" + } + cw.Write([]string{strconv.FormatInt(idv, 10), flight, date, pnum, pname, rnum, task, pens, desc, creator, t, createdAt}) + } + cw.Flush() +} diff --git a/pilots.go b/pilots.go new file mode 100644 index 0000000..ce67b16 --- /dev/null +++ b/pilots.go @@ -0,0 +1,210 @@ +package main + +import ( + "encoding/csv" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" +) + +func registerPilotRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/competitions/{id}/pilots", requireAuth(handleListPilots)) + mux.HandleFunc("POST /api/competitions/{id}/pilots", requireAuth(handleCreatePilot)) + mux.HandleFunc("PATCH /api/competitions/{id}/pilots/{pid}", requireAuth(handleUpdatePilot)) + mux.HandleFunc("DELETE /api/competitions/{id}/pilots/{pid}", requireAuth(handleDeletePilot)) + mux.HandleFunc("POST /api/competitions/{id}/pilots/import", requireAuth(handleImportPilots)) +} + +func requireChiefOrAdmin(r *http.Request, competitionID int64) bool { + u := userFromCtx(r) + role, ok := canAccessCompetition(u, competitionID) + if !ok { + return false + } + return role == "system_admin" || role == "chief_scorer" +} + +func handleListPilots(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + if _, ok := canAccessCompetition(u, id); !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } + rows, err := db.Query("SELECT id,competition_id,number,last_name,first_name,country,balloon_id FROM pilots WHERE competition_id=? ORDER BY number", id) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + defer rows.Close() + out := []Pilot{} + for rows.Next() { + var p Pilot + rows.Scan(&p.ID, &p.CompetitionID, &p.Number, &p.LastName, &p.FirstName, &p.Country, &p.BalloonID) + out = append(out, p) + } + writeJSON(w, http.StatusOK, out) +} + +func handleCreatePilot(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + if !requireChiefOrAdmin(r, id) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var p Pilot + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + p.Number = strings.TrimSpace(p.Number) + if p.Number == "" || p.LastName == "" { + writeError(w, http.StatusBadRequest, "missing_fields") + return + } + res, err := db.Exec( + "INSERT INTO pilots(competition_id,number,last_name,first_name,country,balloon_id) VALUES(?,?,?,?,?,?)", + id, p.Number, p.LastName, p.FirstName, p.Country, p.BalloonID, + ) + if err != nil { + writeError(w, http.StatusConflict, "duplicate_number") + return + } + p.ID, _ = res.LastInsertId() + p.CompetitionID = id + hub.broadcast(id, "pilot_changed", nil) + writeJSON(w, http.StatusCreated, p) +} + +func handleUpdatePilot(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + if !requireChiefOrAdmin(r, id) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var p Pilot + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + _, err = db.Exec( + "UPDATE pilots SET number=?,last_name=?,first_name=?,country=?,balloon_id=? WHERE id=? AND competition_id=?", + p.Number, p.LastName, p.FirstName, p.Country, p.BalloonID, pid, id, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + hub.broadcast(id, "pilot_changed", nil) + w.WriteHeader(http.StatusNoContent) +} + +func handleDeletePilot(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + if !requireChiefOrAdmin(r, id) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + db.Exec("DELETE FROM pilots WHERE id=? AND competition_id=?", pid, id) + hub.broadcast(id, "pilot_changed", nil) + w.WriteHeader(http.StatusNoContent) +} + +func handleImportPilots(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + if !requireChiefOrAdmin(r, id) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, "read_error") + return + } + reader := csv.NewReader(strings.NewReader(string(body))) + reader.FieldsPerRecord = -1 + records, err := reader.ReadAll() + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_csv") + return + } + tx, err := db.Begin() + if err != nil { + writeError(w, http.StatusInternalServerError, "tx_error") + return + } + defer tx.Rollback() + stmt, _ := tx.Prepare(`INSERT INTO pilots(competition_id,number,last_name,first_name,country,balloon_id) + VALUES(?,?,?,?,?,?) + ON CONFLICT(competition_id,number) DO UPDATE SET + last_name=excluded.last_name, first_name=excluded.first_name, + country=excluded.country, balloon_id=excluded.balloon_id`) + defer stmt.Close() + count := 0 + for i, rec := range records { + if i == 0 { + lower := strings.ToLower(strings.TrimSpace(rec[0])) + if lower == "number" || lower == "nummer" || lower == "no" { + continue + } + } + if len(rec) < 3 { + continue + } + number := strings.TrimSpace(rec[0]) + lastName := strings.TrimSpace(rec[1]) + firstName := strings.TrimSpace(rec[2]) + country := "" + balloon := "" + if len(rec) >= 4 { + country = strings.TrimSpace(rec[3]) + } + if len(rec) >= 5 { + balloon = strings.TrimSpace(rec[4]) + } + if number == "" { + continue + } + if _, err := stmt.Exec(id, number, lastName, firstName, country, balloon); err == nil { + count++ + } + } + if err := tx.Commit(); err != nil { + writeError(w, http.StatusInternalServerError, "commit_error") + return + } + hub.broadcast(id, "pilot_changed", nil) + writeJSON(w, http.StatusOK, map[string]int{"imported": count}) +} diff --git a/rules.go b/rules.go new file mode 100644 index 0000000..483284f --- /dev/null +++ b/rules.go @@ -0,0 +1,115 @@ +package main + +import ( + "encoding/csv" + "net/http" + "os" + "path/filepath" + "strings" + "sync" +) + +var ( + rulesMu sync.RWMutex + rules = map[string]map[string]Rule{} +) + +func loadRules(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + out := map[string]map[string]Rule{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".csv") { + continue + } + lang := strings.TrimSuffix(e.Name(), ".csv") + f, err := os.Open(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 { + continue + } + langMap := map[string]Rule{} + for i, rec := range records { + if len(rec) < 4 { + continue + } + if i == 0 { + lower := strings.ToLower(strings.TrimSpace(rec[0])) + if lower == "number" || lower == "rule_number" || lower == "rule" { + continue + } + } + ru := Rule{ + Number: strings.TrimSpace(rec[0]), + Text: strings.TrimSpace(rec[1]), + SuggestedPenalty: strings.TrimSpace(rec[2]), + EscalationMode: strings.TrimSpace(rec[3]), + } + if ru.EscalationMode == "" { + ru.EscalationMode = "same" + } + if strings.HasPrefix(ru.EscalationMode, "escalate:") { + tiers := strings.Split(strings.TrimPrefix(ru.EscalationMode, "escalate:"), "|") + for j, t := range tiers { + tiers[j] = strings.TrimSpace(t) + } + ru.EscalationTiers = tiers + ru.EscalationMode = "escalate" + } + langMap[ru.Number] = ru + } + out[lang] = langMap + } + rulesMu.Lock() + rules = out + rulesMu.Unlock() + return nil +} + +func getRules(lang string) []Rule { + rulesMu.RLock() + defer rulesMu.RUnlock() + m, ok := rules[lang] + if !ok { + m = rules["en"] + } + out := make([]Rule, 0, len(m)) + for _, r := range m { + out = append(out, r) + } + return out +} + +func registerRuleRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/rules", requireAuth(handleListRules)) + mux.HandleFunc("POST /api/rules/reload", requireAdmin(handleReloadRules)) +} + +func handleListRules(w http.ResponseWriter, r *http.Request) { + lang := r.URL.Query().Get("lang") + if lang == "" { + u := userFromCtx(r) + lang = u.Language + } + writeJSON(w, http.StatusOK, getRules(lang)) +} + +func handleReloadRules(w http.ResponseWriter, r *http.Request) { + dir := os.Getenv("RULES_DIR") + if dir == "" { + dir = "rules" + } + if err := loadRules(dir); err != nil { + writeError(w, http.StatusInternalServerError, "load_error") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/rules/de.csv b/rules/de.csv new file mode 100644 index 0000000..a26cd0b --- /dev/null +++ b/rules/de.csv @@ -0,0 +1,44 @@ +rule_number,rule_text,suggested_penalty,escalation_mode +R3.8,Wettbewerbsnummer,,same +R3.8 Wrng.,Wettbewerbsnummer - Warnung,Warning,same +R6.3.2,FRF verspätet,bis zu 100 TP,same +R6.3.2 Wrng.,FRF verspätet - Warnung,Warning,same +R7.5,PZ-Verletzung PZ##,bis zu 1000 CP,same +R7.8,Unzulässige Deklaration,bis zu 100 TP,same +R8.4.2,Aufgabenreihenfolge,bis zu 1000 TP,same +R8.4.7d,Falsches Logger-Ziel,25 TP,same +R8.4.7m,Falscher Marker,25 TP,same +R8.4.8,"Zwei Marker innerhalb MMA, gewertet auf LM",auf elektronischen Mark gewertet,same +R8.12,Verspäteter Eintritt,"50 TP je angef. Min. (>5 Min), 100 TP wenn <5 Min",same +R9.2.2,Landeigentümer-Erlaubnis,bis zu 250 TP,same +R9.3.1,Missachten des Startleiters,bis zu 200 TP,same +R9.3.2,Schnellverschluss nicht benutzt,,same +R9.4.1,Mehr als 1 Fahrzeug im Startbereich,100 TP,same +R9.4.3,Fahrzeug fährt nach gelber Flagge in den Startbereich,100 TP,same +R9.9,Startperiode überschritten,50 TP je angef. Minute,same +R10.1.3,Ballonkollision,bis zu 1000 CP + RFS,same +R10.2.1,Gefährliches Fliegen,bis zur Disqualifikation + RFS,doubled +R10.2.2,Überschreitung der vertikalen Geschwindigkeitsgrenze,BSA + RFS,same +R10.5,Rücksichtsloses Verhalten,bis zu 1000 CP,same +R10.6,Vieh stören oder Feldfrüchte beschädigen,bis zu 1000 CP,same +R10.8,Kollision,bis zu 500 CP,same +R10.11,Fahrweise,bis zu 500 CP,same +R11.4,Bodenkontakt 1,200 TP,same +R11.5l,Bodenkontakt 2 - leicht,100 TP,same +R11.5s,Bodenkontakt 2 - massiv,500 TP,same +R12.3.5,Verspätete Deklaration,50 TP je angef. Minute,same +R12.5,Veränderte oder nicht zugelassene Marker,bis zu 250 TP,same +R12.7,GMD nicht in Ordnung,50 TP,same +R12.7a,GMD nicht in Ordnung (+50m),+50m,same +R12.8,MKR nicht entrollt,50 TP,same +R12.8a,MKR nicht entrollt (+50m),+50m,same +R12.13.3,Außerhalb der Wertungsperiode,NR,same +R13.3,Distanzverletzung,"2 TP je 0,1% Verletzung (ANG, ELB, LRN: Summe der % Verletzung)",same +R13.3>,Distanzverletzung >25%,NR,same +R13.3.1,Start zu nah am Ziel,"2 TP je 0,1% Verletzung",same +R13.3.3,Landung zu nah am Ziel oder MMA,200 TP,same +R15.1,Deklaration ungültig / nicht konform,,same +R15.5.2,Keine gültige Deklaration,,same +R15.12.2,Zeitlimit überschritten,,same +R15.15,Benachbarte Segmente,,same +RII.18.d,BalloonLive- oder BLS-Handhabung,,same diff --git a/rules/en.csv b/rules/en.csv new file mode 100644 index 0000000..5edc53d --- /dev/null +++ b/rules/en.csv @@ -0,0 +1,44 @@ +rule_number,rule_text,suggested_penalty,escalation_mode +R3.8,Competition number,,same +R3.8 Wrng.,Competition number - Warning,Warning,same +R6.3.2,FRF delayed,up to 100 TP,same +R6.3.2 Wrng.,FRF delayed - Warning,Warning,same +R7.5,PZ Infringement PZ##,up to 1000 CP,same +R7.8,Inappropriate declaration,up to 100 TP,same +R8.4.2,Task Order,up to 1000 TP,same +R8.4.7d,Wrong Loggergoal,25 TP,same +R8.4.7m,Wrong Marker,25 TP,same +R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same +R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same +R9.2.2,Landowner Permission,up to 250 TP,same +R9.3.1,Disregard launch master,up to 200 TP,same +R9.3.2,Quick Release not used,,same +R9.4.1,More than 1 vehicle in launch area,100 TP,same +R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same +R9.9,Launch period exceeded,50 TP per part minute,same +R10.1.3,Balloon Collision,up to 1000 CP + RFS,same +R10.2.1,Dangerous flying,up to disqualification + RFS,doubled +R10.2.2,Exceeding vertical speed limits,BSA + RFS,same +R10.5,Inconsiderate behaviour,up to 1000 CP,same +R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same +R10.8,Collision,up to 500 CP,same +R10.11,Way of driving,up to 500 CP,same +R11.4,Ground Contact 1,200 TP,same +R11.5l,Ground Contact 2 - Light,100 TP,same +R11.5s,Ground Contact 2 - Solid,500 TP,same +R12.3.5,Late declaration,50 TP per part minute,same +R12.5,Modified or unauthorized markers,up to 250 TP,same +R12.7,GMD not ok,50 TP,same +R12.7a,GMD not ok (+50m),+50m,same +R12.8,MKR not unrolled,50 TP,same +R12.8a,MKR not unrolled (+50m),+50m,same +R12.13.3,Out of scoring period,NR,same +R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same +R13.3>,Distance Infringement >25%,NR,same +R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same +R13.3.3,Landing too close to goal or MMA,200 TP,same +R15.1,Declaration invalid / non compliant,,same +R15.5.2,No valid declaration,,same +R15.12.2,Exceeded Timing limit,,same +R15.15,Adjacent segments,,same +RII.18.d,BalloonLive or BLS handling,,same diff --git a/rules/es.csv b/rules/es.csv new file mode 100644 index 0000000..5edc53d --- /dev/null +++ b/rules/es.csv @@ -0,0 +1,44 @@ +rule_number,rule_text,suggested_penalty,escalation_mode +R3.8,Competition number,,same +R3.8 Wrng.,Competition number - Warning,Warning,same +R6.3.2,FRF delayed,up to 100 TP,same +R6.3.2 Wrng.,FRF delayed - Warning,Warning,same +R7.5,PZ Infringement PZ##,up to 1000 CP,same +R7.8,Inappropriate declaration,up to 100 TP,same +R8.4.2,Task Order,up to 1000 TP,same +R8.4.7d,Wrong Loggergoal,25 TP,same +R8.4.7m,Wrong Marker,25 TP,same +R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same +R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same +R9.2.2,Landowner Permission,up to 250 TP,same +R9.3.1,Disregard launch master,up to 200 TP,same +R9.3.2,Quick Release not used,,same +R9.4.1,More than 1 vehicle in launch area,100 TP,same +R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same +R9.9,Launch period exceeded,50 TP per part minute,same +R10.1.3,Balloon Collision,up to 1000 CP + RFS,same +R10.2.1,Dangerous flying,up to disqualification + RFS,doubled +R10.2.2,Exceeding vertical speed limits,BSA + RFS,same +R10.5,Inconsiderate behaviour,up to 1000 CP,same +R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same +R10.8,Collision,up to 500 CP,same +R10.11,Way of driving,up to 500 CP,same +R11.4,Ground Contact 1,200 TP,same +R11.5l,Ground Contact 2 - Light,100 TP,same +R11.5s,Ground Contact 2 - Solid,500 TP,same +R12.3.5,Late declaration,50 TP per part minute,same +R12.5,Modified or unauthorized markers,up to 250 TP,same +R12.7,GMD not ok,50 TP,same +R12.7a,GMD not ok (+50m),+50m,same +R12.8,MKR not unrolled,50 TP,same +R12.8a,MKR not unrolled (+50m),+50m,same +R12.13.3,Out of scoring period,NR,same +R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same +R13.3>,Distance Infringement >25%,NR,same +R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same +R13.3.3,Landing too close to goal or MMA,200 TP,same +R15.1,Declaration invalid / non compliant,,same +R15.5.2,No valid declaration,,same +R15.12.2,Exceeded Timing limit,,same +R15.15,Adjacent segments,,same +RII.18.d,BalloonLive or BLS handling,,same diff --git a/rules/fr.csv b/rules/fr.csv new file mode 100644 index 0000000..5edc53d --- /dev/null +++ b/rules/fr.csv @@ -0,0 +1,44 @@ +rule_number,rule_text,suggested_penalty,escalation_mode +R3.8,Competition number,,same +R3.8 Wrng.,Competition number - Warning,Warning,same +R6.3.2,FRF delayed,up to 100 TP,same +R6.3.2 Wrng.,FRF delayed - Warning,Warning,same +R7.5,PZ Infringement PZ##,up to 1000 CP,same +R7.8,Inappropriate declaration,up to 100 TP,same +R8.4.2,Task Order,up to 1000 TP,same +R8.4.7d,Wrong Loggergoal,25 TP,same +R8.4.7m,Wrong Marker,25 TP,same +R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same +R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same +R9.2.2,Landowner Permission,up to 250 TP,same +R9.3.1,Disregard launch master,up to 200 TP,same +R9.3.2,Quick Release not used,,same +R9.4.1,More than 1 vehicle in launch area,100 TP,same +R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same +R9.9,Launch period exceeded,50 TP per part minute,same +R10.1.3,Balloon Collision,up to 1000 CP + RFS,same +R10.2.1,Dangerous flying,up to disqualification + RFS,doubled +R10.2.2,Exceeding vertical speed limits,BSA + RFS,same +R10.5,Inconsiderate behaviour,up to 1000 CP,same +R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same +R10.8,Collision,up to 500 CP,same +R10.11,Way of driving,up to 500 CP,same +R11.4,Ground Contact 1,200 TP,same +R11.5l,Ground Contact 2 - Light,100 TP,same +R11.5s,Ground Contact 2 - Solid,500 TP,same +R12.3.5,Late declaration,50 TP per part minute,same +R12.5,Modified or unauthorized markers,up to 250 TP,same +R12.7,GMD not ok,50 TP,same +R12.7a,GMD not ok (+50m),+50m,same +R12.8,MKR not unrolled,50 TP,same +R12.8a,MKR not unrolled (+50m),+50m,same +R12.13.3,Out of scoring period,NR,same +R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same +R13.3>,Distance Infringement >25%,NR,same +R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same +R13.3.3,Landing too close to goal or MMA,200 TP,same +R15.1,Declaration invalid / non compliant,,same +R15.5.2,No valid declaration,,same +R15.12.2,Exceeded Timing limit,,same +R15.15,Adjacent segments,,same +RII.18.d,BalloonLive or BLS handling,,same diff --git a/rules/pl.csv b/rules/pl.csv new file mode 100644 index 0000000..5edc53d --- /dev/null +++ b/rules/pl.csv @@ -0,0 +1,44 @@ +rule_number,rule_text,suggested_penalty,escalation_mode +R3.8,Competition number,,same +R3.8 Wrng.,Competition number - Warning,Warning,same +R6.3.2,FRF delayed,up to 100 TP,same +R6.3.2 Wrng.,FRF delayed - Warning,Warning,same +R7.5,PZ Infringement PZ##,up to 1000 CP,same +R7.8,Inappropriate declaration,up to 100 TP,same +R8.4.2,Task Order,up to 1000 TP,same +R8.4.7d,Wrong Loggergoal,25 TP,same +R8.4.7m,Wrong Marker,25 TP,same +R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same +R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same +R9.2.2,Landowner Permission,up to 250 TP,same +R9.3.1,Disregard launch master,up to 200 TP,same +R9.3.2,Quick Release not used,,same +R9.4.1,More than 1 vehicle in launch area,100 TP,same +R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same +R9.9,Launch period exceeded,50 TP per part minute,same +R10.1.3,Balloon Collision,up to 1000 CP + RFS,same +R10.2.1,Dangerous flying,up to disqualification + RFS,doubled +R10.2.2,Exceeding vertical speed limits,BSA + RFS,same +R10.5,Inconsiderate behaviour,up to 1000 CP,same +R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same +R10.8,Collision,up to 500 CP,same +R10.11,Way of driving,up to 500 CP,same +R11.4,Ground Contact 1,200 TP,same +R11.5l,Ground Contact 2 - Light,100 TP,same +R11.5s,Ground Contact 2 - Solid,500 TP,same +R12.3.5,Late declaration,50 TP per part minute,same +R12.5,Modified or unauthorized markers,up to 250 TP,same +R12.7,GMD not ok,50 TP,same +R12.7a,GMD not ok (+50m),+50m,same +R12.8,MKR not unrolled,50 TP,same +R12.8a,MKR not unrolled (+50m),+50m,same +R12.13.3,Out of scoring period,NR,same +R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same +R13.3>,Distance Infringement >25%,NR,same +R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same +R13.3.3,Landing too close to goal or MMA,200 TP,same +R15.1,Declaration invalid / non compliant,,same +R15.5.2,No valid declaration,,same +R15.12.2,Exceeded Timing limit,,same +R15.15,Adjacent segments,,same +RII.18.d,BalloonLive or BLS handling,,same diff --git a/rules/pt.csv b/rules/pt.csv new file mode 100644 index 0000000..5edc53d --- /dev/null +++ b/rules/pt.csv @@ -0,0 +1,44 @@ +rule_number,rule_text,suggested_penalty,escalation_mode +R3.8,Competition number,,same +R3.8 Wrng.,Competition number - Warning,Warning,same +R6.3.2,FRF delayed,up to 100 TP,same +R6.3.2 Wrng.,FRF delayed - Warning,Warning,same +R7.5,PZ Infringement PZ##,up to 1000 CP,same +R7.8,Inappropriate declaration,up to 100 TP,same +R8.4.2,Task Order,up to 1000 TP,same +R8.4.7d,Wrong Loggergoal,25 TP,same +R8.4.7m,Wrong Marker,25 TP,same +R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same +R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same +R9.2.2,Landowner Permission,up to 250 TP,same +R9.3.1,Disregard launch master,up to 200 TP,same +R9.3.2,Quick Release not used,,same +R9.4.1,More than 1 vehicle in launch area,100 TP,same +R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same +R9.9,Launch period exceeded,50 TP per part minute,same +R10.1.3,Balloon Collision,up to 1000 CP + RFS,same +R10.2.1,Dangerous flying,up to disqualification + RFS,doubled +R10.2.2,Exceeding vertical speed limits,BSA + RFS,same +R10.5,Inconsiderate behaviour,up to 1000 CP,same +R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same +R10.8,Collision,up to 500 CP,same +R10.11,Way of driving,up to 500 CP,same +R11.4,Ground Contact 1,200 TP,same +R11.5l,Ground Contact 2 - Light,100 TP,same +R11.5s,Ground Contact 2 - Solid,500 TP,same +R12.3.5,Late declaration,50 TP per part minute,same +R12.5,Modified or unauthorized markers,up to 250 TP,same +R12.7,GMD not ok,50 TP,same +R12.7a,GMD not ok (+50m),+50m,same +R12.8,MKR not unrolled,50 TP,same +R12.8a,MKR not unrolled (+50m),+50m,same +R12.13.3,Out of scoring period,NR,same +R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same +R13.3>,Distance Infringement >25%,NR,same +R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same +R13.3.3,Landing too close to goal or MMA,200 TP,same +R15.1,Declaration invalid / non compliant,,same +R15.5.2,No valid declaration,,same +R15.12.2,Exceeded Timing limit,,same +R15.15,Adjacent segments,,same +RII.18.d,BalloonLive or BLS handling,,same diff --git a/rules/ru.csv b/rules/ru.csv new file mode 100644 index 0000000..5edc53d --- /dev/null +++ b/rules/ru.csv @@ -0,0 +1,44 @@ +rule_number,rule_text,suggested_penalty,escalation_mode +R3.8,Competition number,,same +R3.8 Wrng.,Competition number - Warning,Warning,same +R6.3.2,FRF delayed,up to 100 TP,same +R6.3.2 Wrng.,FRF delayed - Warning,Warning,same +R7.5,PZ Infringement PZ##,up to 1000 CP,same +R7.8,Inappropriate declaration,up to 100 TP,same +R8.4.2,Task Order,up to 1000 TP,same +R8.4.7d,Wrong Loggergoal,25 TP,same +R8.4.7m,Wrong Marker,25 TP,same +R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same +R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same +R9.2.2,Landowner Permission,up to 250 TP,same +R9.3.1,Disregard launch master,up to 200 TP,same +R9.3.2,Quick Release not used,,same +R9.4.1,More than 1 vehicle in launch area,100 TP,same +R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same +R9.9,Launch period exceeded,50 TP per part minute,same +R10.1.3,Balloon Collision,up to 1000 CP + RFS,same +R10.2.1,Dangerous flying,up to disqualification + RFS,doubled +R10.2.2,Exceeding vertical speed limits,BSA + RFS,same +R10.5,Inconsiderate behaviour,up to 1000 CP,same +R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same +R10.8,Collision,up to 500 CP,same +R10.11,Way of driving,up to 500 CP,same +R11.4,Ground Contact 1,200 TP,same +R11.5l,Ground Contact 2 - Light,100 TP,same +R11.5s,Ground Contact 2 - Solid,500 TP,same +R12.3.5,Late declaration,50 TP per part minute,same +R12.5,Modified or unauthorized markers,up to 250 TP,same +R12.7,GMD not ok,50 TP,same +R12.7a,GMD not ok (+50m),+50m,same +R12.8,MKR not unrolled,50 TP,same +R12.8a,MKR not unrolled (+50m),+50m,same +R12.13.3,Out of scoring period,NR,same +R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same +R13.3>,Distance Infringement >25%,NR,same +R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same +R13.3.3,Landing too close to goal or MMA,200 TP,same +R15.1,Declaration invalid / non compliant,,same +R15.5.2,No valid declaration,,same +R15.12.2,Exceeded Timing limit,,same +R15.15,Adjacent segments,,same +RII.18.d,BalloonLive or BLS handling,,same diff --git a/users.go b/users.go new file mode 100644 index 0000000..ee66f88 --- /dev/null +++ b/users.go @@ -0,0 +1,141 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +func registerUserRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/users", requireAdmin(handleListUsers)) + mux.HandleFunc("POST /api/users", requireAuth(handleCreateUser)) + mux.HandleFunc("DELETE /api/users/{id}", requireAdmin(handleDeleteUser)) + mux.HandleFunc("PATCH /api/users/{id}", requireAdmin(handleAdminUpdateUser)) +} + +func handleListUsers(w http.ResponseWriter, r *http.Request) { + rows, err := db.Query("SELECT id,username,display_name,language,is_system_admin FROM users ORDER BY username") + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + defer rows.Close() + out := []User{} + for rows.Next() { + var u User + var admin int + rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin) + u.IsSystemAdmin = admin == 1 + out = append(out, u) + } + writeJSON(w, http.StatusOK, out) +} + +func handleCreateUser(w http.ResponseWriter, r *http.Request) { + actor := userFromCtx(r) + var req struct { + Username string `json:"username"` + Password string `json:"password"` + DisplayName string `json:"display_name"` + Language string `json:"language"` + IsSystemAdmin bool `json:"is_system_admin"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + req.Username = strings.TrimSpace(req.Username) + if req.Username == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "missing_fields") + return + } + if req.IsSystemAdmin && !actor.IsSystemAdmin { + writeError(w, http.StatusForbidden, "forbidden") + return + } + if !actor.IsSystemAdmin { + var chiefCount int + db.QueryRow("SELECT COUNT(*) FROM competition_users WHERE user_id=? AND role='chief_scorer'", actor.ID).Scan(&chiefCount) + if chiefCount == 0 { + writeError(w, http.StatusForbidden, "forbidden") + return + } + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + writeError(w, http.StatusInternalServerError, "hash_error") + return + } + if req.Language == "" { + req.Language = "en" + } + admin := 0 + if req.IsSystemAdmin { + admin = 1 + } + res, err := db.Exec( + "INSERT INTO users(username,password_hash,display_name,language,is_system_admin) VALUES(?,?,?,?,?)", + req.Username, string(hash), req.DisplayName, req.Language, admin, + ) + if err != nil { + writeError(w, http.StatusConflict, "username_taken") + return + } + id, _ := res.LastInsertId() + u, _ := loadUser(id) + writeJSON(w, http.StatusCreated, u) +} + +func handleDeleteUser(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + actor := userFromCtx(r) + if actor.ID == id { + writeError(w, http.StatusBadRequest, "cannot_delete_self") + return + } + if _, err := db.Exec("DELETE FROM users WHERE id=?", id); err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + var req struct { + DisplayName *string `json:"display_name"` + Password *string `json:"password"` + IsSystemAdmin *bool `json:"is_system_admin"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + if req.DisplayName != nil { + db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, id) + } + if req.Password != nil && *req.Password != "" { + hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + db.Exec("UPDATE users SET password_hash=? WHERE id=?", string(hash), id) + } + if req.IsSystemAdmin != nil { + v := 0 + if *req.IsSystemAdmin { + v = 1 + } + db.Exec("UPDATE users SET is_system_admin=? WHERE id=?", v, id) + } + u, _ := loadUser(id) + writeJSON(w, http.StatusOK, u) +} diff --git a/web/api.js b/web/api.js new file mode 100644 index 0000000..82e7e11 --- /dev/null +++ b/web/api.js @@ -0,0 +1,96 @@ +async function api(method, path, body) { + const opts = { + method, + headers: { "Content-Type": "application/json" }, + credentials: "include", + }; + if (body !== undefined) opts.body = JSON.stringify(body); + const res = await fetch(apiURL(path), opts); + if (res.status === 204) return null; + let data = null; + const text = await res.text(); + if (text) { + try { data = JSON.parse(text); } catch (e) { data = text; } + } + if (!res.ok) { + const err = new Error((data && data.error) || res.statusText); + err.status = res.status; + err.data = data; + throw err; + } + return data; +} + +const API = { + login: (u, p) => api("POST", "/api/login", { username: u, password: p }), + logout: () => api("POST", "/api/logout"), + me: () => api("GET", "/api/me"), + updateMe: (b) => api("PATCH", "/api/me", b), + + listUsers: () => api("GET", "/api/users"), + createUser: (b) => api("POST", "/api/users", b), + deleteUser: (id) => api("DELETE", `/api/users/${id}`), + + listCompetitions: () => api("GET", "/api/competitions"), + createCompetition: (b) => api("POST", "/api/competitions", b), + getCompetition: (id) => api("GET", `/api/competitions/${id}`), + updateCompetition: (id, b) => api("PATCH", `/api/competitions/${id}`, b), + deleteCompetition: (id) => api("DELETE", `/api/competitions/${id}`), + + listMembers: (id) => api("GET", `/api/competitions/${id}/members`), + addMember: (id, b) => api("POST", `/api/competitions/${id}/members`, b), + removeMember: (id, uid) => api("DELETE", `/api/competitions/${id}/members/${uid}`), + + listPilots: (id) => api("GET", `/api/competitions/${id}/pilots`), + createPilot: (id, b) => api("POST", `/api/competitions/${id}/pilots`, b), + updatePilot: (id, pid, b) => api("PATCH", `/api/competitions/${id}/pilots/${pid}`, b), + deletePilot: (id, pid) => api("DELETE", `/api/competitions/${id}/pilots/${pid}`), + importPilots: async (id, csv) => { + const res = await fetch(apiURL(`/api/competitions/${id}/pilots/import`), { + method: "POST", + headers: { "Content-Type": "text/csv" }, + body: csv, + credentials: "include", + }); + if (!res.ok) throw new Error("import_failed"); + return res.json(); + }, + + listPenalties: (id) => api("GET", `/api/competitions/${id}/penalties`), + createPenalty: (id, b) => api("POST", `/api/competitions/${id}/penalties`, b), + updatePenalty: (id, pid, b) => api("PATCH", `/api/competitions/${id}/penalties/${pid}`, b), + deletePenalty: (id, pid) => api("DELETE", `/api/competitions/${id}/penalties/${pid}`), + exportPenaltiesURL: (id) => apiURL(`/api/competitions/${id}/penalties.csv`), + + listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`), +}; + +function openCompetitionWS(id, handlers) { + const url = wsURL(`/api/competitions/${id}/ws`); + let ws = null; + let closed = false; + let backoff = 1000; + + function connect() { + ws = new WebSocket(url); + ws.onopen = () => { backoff = 1000; if (handlers.onopen) handlers.onopen(); }; + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + if (handlers.onmessage) handlers.onmessage(msg); + } catch (err) {} + }; + ws.onclose = () => { + if (handlers.onclose) handlers.onclose(); + if (!closed) { + setTimeout(connect, backoff); + backoff = Math.min(backoff * 2, 15000); + } + }; + ws.onerror = () => { try { ws.close(); } catch (e) {} }; + } + connect(); + return { + close: () => { closed = true; try { ws && ws.close(); } catch (e) {} } + }; +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..3bf935d --- /dev/null +++ b/web/app.js @@ -0,0 +1,1163 @@ +const el = (tag, attrs, ...children) => { + const node = document.createElement(tag); + if (attrs) { + for (const k in attrs) { + if (k === "class") node.className = attrs[k]; + else if (k === "style") Object.assign(node.style, attrs[k]); + else if (k.startsWith("on") && typeof attrs[k] === "function") node.addEventListener(k.slice(2), attrs[k]); + else if (k === "checked" || k === "disabled" || k === "selected") { + if (attrs[k]) node.setAttribute(k, ""); + } else if (attrs[k] !== false && attrs[k] !== null && attrs[k] !== undefined) { + node.setAttribute(k, attrs[k]); + } + } + } + for (const c of children.flat()) { + if (c === null || c === undefined || c === false) continue; + node.appendChild(typeof c === "string" || typeof c === "number" ? document.createTextNode(String(c)) : c); + } + return node; +}; + +const root = document.getElementById("app"); +const state = { + user: null, + competitions: [], + competition: null, + pilots: [], + penalties: [], + members: [], + users: [], + rules: [], + rulesByNumber: {}, + ws: null, + tab: "penalties", + sort: { col: "id", dir: "desc" }, + pilotSort: { col: "number", dir: "asc" }, + filterUntransferred: false, + editingPenalty: null, + showPenaltyModal: false, + showPilotModal: false, + showMemberModal: false, + showCompetitionModal: false, + showUserModal: false, +}; + +function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); } + +function render() { + if (!state.user) { + renderLogin(); + return; + } + if (state.competition) { + renderCompetition(); + } else { + renderHome(); + } +} + +function renderLogin() { + clear(root); + const usernameInput = el("input", { type: "text", autocomplete: "username", placeholder: t("username") }); + const passwordInput = el("input", { type: "password", autocomplete: "current-password", placeholder: t("password") }); + const errorBox = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } }); + const langSelect = el("select", + { onchange: (e) => { setLang(e.target.value); renderLogin(); } }, + ...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === CURRENT_LANG }, I18N_NAMES[l])) + ); + + async function doLogin(e) { + e.preventDefault(); + errorBox.style.display = "none"; + try { + const u = await API.login(usernameInput.value, passwordInput.value); + state.user = u; + setLang(u.language); + await loadCompetitions(); + render(); + } catch (err) { + errorBox.textContent = err.status === 401 ? t("invalid_credentials") : (err.message || t("invalid_credentials")); + errorBox.style.display = "block"; + } + } + + const form = el("form", { onsubmit: doLogin, class: "col" }, + el("h1", null, t("login_title")), + el("div", { class: "field" }, el("label", null, t("username")), usernameInput), + el("div", { class: "field" }, el("label", null, t("password")), passwordInput), + errorBox, + el("button", { type: "submit", class: "primary" }, t("sign_in")), + el("div", { class: "field", style: { marginTop: "0.5rem" } }, + el("label", null, t("language")), langSelect + ), + ); + + root.appendChild(el("div", { class: "login-wrap" }, el("div", { class: "login-box" }, form))); + usernameInput.focus(); +} + +function renderTopbar(extra) { + const langSelect = el("select", + { onchange: async (e) => { + const lang = e.target.value; + await API.updateMe({ language: lang }); + state.user.language = lang; + setLang(lang); + await loadRules(); + render(); + } }, + ...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === state.user.language }, I18N_NAMES[l])) + ); + const logoutBtn = el("button", { class: "ghost", onclick: async () => { + await API.logout(); + if (state.ws) { state.ws.close(); state.ws = null; } + state.user = null; + state.competition = null; + render(); + } }, t("logout")); + + const brand = el("a", { href: "#", onclick: (e) => { + e.preventDefault(); + if (state.ws) { state.ws.close(); state.ws = null; } + state.competition = null; + render(); + } , class: "brand" }, "Penalty Tracker"); + + const profileBtn = el("button", { class: "ghost", onclick: () => openProfileModal() }, + state.user.display_name || state.user.username); + + return el("div", { class: "topbar" }, + brand, + el("div", { class: "nav" }, + extra || null, + profileBtn, + langSelect, + logoutBtn, + ) + ); +} + +function openProfileModal() { + state.showProfileModal = true; + render(); +} + +function renderProfileModal() { + const username = el("input", { type: "text", value: state.user.username }); + const displayName = el("input", { type: "text", value: state.user.display_name || "" }); + const password = el("input", { type: "password", placeholder: t("leave_blank_keep") }); + const err = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } }); + const ok = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } }); + return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, + el("div", { class: "modal" }, + el("h3", null, t("profile")), + 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), + err, ok, + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: closeAll }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + err.style.display = "none"; ok.style.display = "none"; + const body = {}; + const newUsername = username.value.trim(); + if (newUsername && newUsername !== state.user.username) body.username = newUsername; + if (displayName.value !== (state.user.display_name || "")) body.display_name = displayName.value; + if (password.value) body.password = password.value; + try { + const u = await API.updateMe(body); + state.user = u; + ok.textContent = t("saved"); + ok.style.display = "block"; + setTimeout(() => { closeAll(); }, 700); + } catch (e) { + err.textContent = e.data && e.data.error === "username_taken" ? t("username_taken") : (e.message || "error"); + err.style.display = "block"; + } + } }, t("save")), + ), + ) + ); +} + +async function loadCompetitions() { + state.competitions = await API.listCompetitions(); +} + +async function loadRules() { + state.rules = await API.listRules(state.user.language); + state.rulesByNumber = {}; + for (const r of state.rules) state.rulesByNumber[r.number] = r; +} + +function renderHome() { + clear(root); + root.appendChild(renderTopbar()); + const container = el("div", { class: "container" }); + + const headerRow = el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.75rem" } }, + el("h2", { style: { margin: 0 } }, t("competitions")), + state.user.is_system_admin && el("button", { class: "primary", onclick: () => openCompetitionModal() }, t("new_competition")) + ); + container.appendChild(headerRow); + + if (state.competitions.length === 0) { + container.appendChild(el("div", { class: "muted" }, t("no_competitions"))); + } else { + const list = el("div", { class: "grid" }); + for (const c of state.competitions) { + list.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)), + ), + el("button", { class: "primary", onclick: () => openCompetition(c.id) }, t("open")), + )); + } + container.appendChild(list); + } + + if (state.user.is_system_admin) { + container.appendChild(renderUsersAdmin()); + } + + root.appendChild(container); + + if (state.showCompetitionModal) container.appendChild(renderCompetitionModal()); + if (state.showUserModal) container.appendChild(renderUserModal()); + if (state.showProfileModal) container.appendChild(renderProfileModal()); +} + +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("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.id !== state.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); + card.appendChild(el("div", { class: "table-wrap" }, table)); + return card; +} + +async function loadUsers() { + if (state.user.is_system_admin) { + state.users = await API.listUsers(); + } +} + +function openCompetitionModal() { + state.showCompetitionModal = true; + render(); +} + +function renderCompetitionModal() { + const nameInput = el("input", { type: "text" }); + const allowInput = el("input", { type: "checkbox" }); + const modal = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === modal) closeAll(); } }, + el("div", { class: "modal" }, + el("h3", null, t("new_competition")), + el("div", { class: "field" }, el("label", null, t("competition_name")), nameInput), + el("label", { class: "row" }, allowInput, " " + t("allow_any_scorer_edit")), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: closeAll }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + if (!nameInput.value.trim()) return; + await API.createCompetition({ name: nameInput.value.trim(), allow_any_scorer_edit: allowInput.checked }); + await loadCompetitions(); + closeAll(); + } }, t("create")), + ), + ) + ); + return modal; +} + +function openUserModal(forCompetition) { + state.showUserModal = true; + state.userModalForCompetition = forCompetition || null; + render(); +} + +function renderUserModal() { + const username = el("input", { type: "text" }); + 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" }); + const roleSelect = el("select", null, + el("option", { value: "scorer" }, t("scorer")), + el("option", { value: "chief_scorer" }, t("chief_scorer")), + ); + const forComp = state.userModalForCompetition; + return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, + 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), + !forComp && state.user.is_system_admin && el("label", { class: "row" }, isAdmin, " " + t("is_admin")), + forComp && el("div", { class: "field" }, el("label", null, t("role")), roleSelect), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: closeAll }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + if (!username.value.trim() || !password.value) return; + try { + const created = await API.createUser({ + username: username.value.trim(), + password: password.value, + display_name: displayName.value, + language: langSelect.value, + is_system_admin: !forComp && isAdmin.checked, + }); + if (forComp) { + await API.addMember(forComp, { user_id: created.id, role: roleSelect.value }); + await loadMembers(); + } + await loadUsers(); + closeAll(); + } catch (err) { + alert(err.message); + } + } }, t("create")), + ), + ) + ); +} + +function closeAll() { + state.showCompetitionModal = false; + state.showUserModal = false; + state.showPilotModal = false; + state.showPenaltyModal = false; + state.showMemberModal = false; + state.showImportModal = false; + state.showProfileModal = false; + state.editingPenalty = null; + state.editingPilot = null; + state.userModalForCompetition = null; + render(); +} + +async function openCompetition(id) { + const c = await API.getCompetition(id); + state.competition = c; + state.tab = "penalties"; + await Promise.all([loadPilots(), loadPenalties(), loadMembers(), loadRules()]); + if (state.user.is_system_admin) await loadUsers(); + if (state.ws) state.ws.close(); + state.ws = openCompetitionWS(id, { + onopen: () => { state.wsOnline = true; updateStatus(); }, + onclose: () => { state.wsOnline = false; updateStatus(); }, + onmessage: handleWSMessage, + }); + render(); +} + +function updateStatus() { + const el = document.querySelector(".connection-status"); + if (!el) return; + el.classList.toggle("online", !!state.wsOnline); + el.classList.toggle("offline", !state.wsOnline); +} + +function handleWSMessage(msg) { + if (msg.type === "penalty_created") { + const exists = state.penalties.some((p) => p.id === msg.payload.id); + if (!exists) { + state.penalties.unshift(msg.payload); + patchPenaltyTable(); + } + } else if (msg.type === "penalty_updated") { + const idx = state.penalties.findIndex((p) => p.id === msg.payload.id); + if (idx >= 0) { + state.penalties[idx] = msg.payload; + patchPenaltyTable(); + } + } else if (msg.type === "penalty_deleted") { + state.penalties = state.penalties.filter((p) => p.id !== msg.payload.id); + patchPenaltyTable(); + } else if (msg.type === "pilot_changed") { + Promise.all([loadPilots(), loadPenalties()]).then(() => { + if (state.tab === "penalties") patchPenaltyTable(); + else render(); + }); + } else if (msg.type === "competition_updated") { + API.getCompetition(state.competition.id).then((c) => { state.competition = c; render(); }); + } +} + +async function loadPilots() { state.pilots = await API.listPilots(state.competition.id); } +async function loadPenalties() { state.penalties = await API.listPenalties(state.competition.id); } +async function loadMembers() { state.members = await API.listMembers(state.competition.id); } + +function isChief() { + const r = state.competition.role; + return r === "system_admin" || r === "chief_scorer"; +} + +function canEditPenalty(p) { + if (isChief()) return true; + if (p.created_by === state.user.id) return true; + return !!state.competition.allow_any_scorer_edit; +} + +function renderCompetition() { + clear(root); + const backBtn = el("button", { class: "ghost", onclick: () => { + if (state.ws) { state.ws.close(); state.ws = null; } + state.competition = null; + render(); + } }, "← " + t("back")); + root.appendChild(renderTopbar(backBtn)); + + const container = el("div", { class: "container" }); + container.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } }, + el("h2", { style: { margin: 0 } }, + state.competition.name, + " ", + el("span", { class: "badge accent" }, t(state.competition.role)), + ), + 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 tabDefs = [ + ["penalties", t("penalties")], + ["pilots", t("pilots")], + ["rules", t("rules")], + ]; + if (isChief()) { + tabDefs.push(["members", t("members")]); + tabDefs.push(["settings", t("settings_tab")]); + } + for (const [id, label] of tabDefs) { + tabs.appendChild(el("button", { + class: state.tab === id ? "active" : "", + onclick: () => { state.tab = id; render(); } + }, label)); + } + container.appendChild(tabs); + + if (state.tab === "penalties") container.appendChild(renderPenaltiesTab()); + else if (state.tab === "pilots") container.appendChild(renderPilotsTab()); + else if (state.tab === "rules") container.appendChild(renderRulesTab()); + else if (state.tab === "members") container.appendChild(renderMembersTab()); + else if (state.tab === "settings") container.appendChild(renderSettingsTab()); + + root.appendChild(container); + + if (state.showPenaltyModal) container.appendChild(renderPenaltyModal()); + if (state.showPilotModal) container.appendChild(renderPilotModal()); + if (state.showMemberModal) container.appendChild(renderMemberModal()); + if (state.showUserModal) container.appendChild(renderUserModal()); + if (state.showImportModal) container.appendChild(renderImportModal()); + if (state.showProfileModal) container.appendChild(renderProfileModal()); +} + +const PENALTY_COLS = [ + { key: "id", label: "#" }, + { key: "flight", label: "flight" }, + { key: "date", label: "date" }, + { key: "pilot_number", label: "pilot_number" }, + { key: "pilot_name", label: "pilot_name" }, + { key: "rule_number", label: "rule_number_short" }, + { key: "task", label: "task" }, + { key: "penalties_text", label: "penalty_values" }, + { key: "description", label: "description" }, + { key: "created_by_name", label: "created_by" }, + { key: "transferred", label: "transferred" }, +]; + +function sortPenalties(list) { + const { col, dir } = state.sort; + const sign = dir === "asc" ? 1 : -1; + return [...list].sort((a, b) => { + let av = a[col], bv = b[col]; + if (typeof av === "boolean") av = av ? 1 : 0; + if (typeof bv === "boolean") bv = bv ? 1 : 0; + if (av == null) av = ""; + if (bv == null) bv = ""; + if (typeof av === "number" && typeof bv === "number") return (av - bv) * sign; + return String(av).localeCompare(String(bv), undefined, { numeric: true }) * sign; + }); +} + +function filterPenalties(list) { + if (state.filterUntransferred) list = list.filter((p) => !p.transferred); + return list; +} + +function renderPenaltiesTab() { + const card = el("div"); + + const toolbar = el("div", { class: "toolbar" }, + el("button", { class: "primary", onclick: () => openPenaltyModal() }, t("add_penalty")), + el("label", { class: "row" }, + el("input", { type: "checkbox", checked: state.filterUntransferred, + onchange: (e) => { state.filterUntransferred = e.target.checked; patchPenaltyTable(); } + }), + " " + t("transferred_only") + ), + el("div", { class: "spacer" }), + isChief() && el("a", { href: API.exportPenaltiesURL(state.competition.id), target: "_blank" }, + el("button", null, t("export_csv")) + ), + ); + card.appendChild(toolbar); + + const tableWrap = el("div", { class: "table-wrap", id: "penalty-table-wrap" }); + const table = el("table", { id: "penalty-table" }); + const thead = el("thead"); + const headRow = el("tr"); + for (const c of PENALTY_COLS) { + headRow.appendChild(el("th", { + onclick: () => { + if (state.sort.col === c.key) state.sort.dir = state.sort.dir === "asc" ? "desc" : "asc"; + else { state.sort.col = c.key; state.sort.dir = "asc"; } + patchPenaltyTable(); + } + }, + t(c.label), + el("span", { class: "sort-ind" }, state.sort.col === c.key ? (state.sort.dir === "asc" ? "▲" : "▼") : ""), + )); + } + headRow.appendChild(el("th", null, t("actions"))); + thead.appendChild(headRow); + table.appendChild(thead); + + const tbody = el("tbody", { id: "penalty-tbody" }); + table.appendChild(tbody); + tableWrap.appendChild(table); + card.appendChild(tableWrap); + + setTimeout(patchPenaltyTable, 0); + return card; +} + +function patchPenaltyTable() { + const tbody = document.getElementById("penalty-tbody"); + if (!tbody) return; + const list = sortPenalties(filterPenalties(state.penalties)); + + const headRow = document.querySelector("#penalty-table thead tr"); + if (headRow) { + const ths = headRow.querySelectorAll("th"); + PENALTY_COLS.forEach((c, i) => { + const ind = ths[i].querySelector(".sort-ind"); + if (ind) ind.textContent = state.sort.col === c.key ? (state.sort.dir === "asc" ? "▲" : "▼") : ""; + }); + } + + const existing = new Map(); + for (const tr of Array.from(tbody.children)) { + existing.set(tr.dataset.id, tr); + } + const wanted = new Set(); + let prev = null; + for (const p of list) { + const idStr = String(p.id); + wanted.add(idStr); + let tr = existing.get(idStr); + if (!tr) { + tr = buildPenaltyRow(p); + } else { + updatePenaltyRow(tr, p); + } + if (prev) { + if (prev.nextSibling !== tr) tbody.insertBefore(tr, prev.nextSibling); + } else { + if (tbody.firstChild !== tr) tbody.insertBefore(tr, tbody.firstChild); + } + prev = tr; + } + for (const [k, tr] of existing) { + if (!wanted.has(k)) tr.remove(); + } +} + +function buildPenaltyRow(p) { + const tr = document.createElement("tr"); + tr.dataset.id = String(p.id); + for (const c of PENALTY_COLS) { + const td = document.createElement("td"); + td.dataset.col = c.key; + tr.appendChild(td); + } + const actions = document.createElement("td"); + actions.dataset.col = "_actions"; + tr.appendChild(actions); + updatePenaltyRow(tr, p); + return tr; +} + +function updatePenaltyRow(tr, p) { + tr.classList.toggle("transferred", !!p.transferred); + for (const c of PENALTY_COLS) { + const td = tr.querySelector(`td[data-col="${c.key}"]`); + if (!td) continue; + if (c.key === "transferred") { + td.innerHTML = ""; + const lbl = document.createElement("label"); + lbl.className = "switch"; + const inp = document.createElement("input"); + inp.type = "checkbox"; + inp.checked = !!p.transferred; + inp.disabled = !canEditPenalty(p); + inp.addEventListener("change", async () => { + try { + await API.updatePenalty(state.competition.id, p.id, { transferred: inp.checked }); + } catch (e) { inp.checked = !inp.checked; alert(e.message); } + }); + const slider = document.createElement("span"); + slider.className = "slider"; + lbl.appendChild(inp); + lbl.appendChild(slider); + td.appendChild(lbl); + } else { + const val = p[c.key]; + td.textContent = val == null ? "" : String(val); + } + } + const actions = tr.querySelector('td[data-col="_actions"]'); + actions.innerHTML = ""; + if (canEditPenalty(p)) { + const edit = document.createElement("button"); + edit.className = "action-btn"; + edit.textContent = t("edit"); + edit.addEventListener("click", () => openPenaltyModal(p)); + const del = document.createElement("button"); + del.className = "action-btn danger"; + del.textContent = t("delete"); + del.addEventListener("click", async () => { + if (!confirm(t("confirm_delete"))) return; + await API.deletePenalty(state.competition.id, p.id); + }); + actions.appendChild(edit); + actions.appendChild(del); + } +} + +function openPenaltyModal(penalty) { + state.showPenaltyModal = true; + state.editingPenalty = penalty || null; + render(); +} + +function renderPenaltyModal() { + const p = state.editingPenalty || {}; + const flight = el("input", { type: "text", value: p.flight || "" }); + const date = el("input", { type: "date", value: p.date || new Date().toISOString().slice(0, 10) }); + const pilotSelect = el("select", { onchange: () => refreshPrior() }, + el("option", { value: "" }, "—"), + ...sortPilots(state.pilots).map((pl) => el("option", { value: pl.number, selected: pl.number === p.pilot_number }, + `${pl.number} — ${pl.last_name}, ${pl.first_name}`)) + ); + const existingRule = p.rule_number ? state.rulesByNumber[p.rule_number] : null; + const ruleSearch = el("input", { type: "text", placeholder: t("search_rule"), + value: existingRule ? `${existingRule.number} — ${existingRule.text}` : (p.rule_number || "") }); + const ruleNumber = el("input", { type: "text", placeholder: t("rule_number_short"), value: p.rule_number || "" }); + const suggestionBox = el("div", { class: "rule-card", style: { display: "none" } }); + const searchResults = el("div", { class: "results", style: { display: "none" } }); + const priorBox = el("div", { class: "prior-box", style: { display: "none" } }); + + function escalationViz(r) { + if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same")); + if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled")); + if (r.escalation_mode === "escalate") { + const wrap = el("div", { class: "tier-row" }); + const tiers = r.escalation_tiers || []; + tiers.forEach((tier, i) => { + wrap.appendChild(el("span", { class: "tier-pill" }, tier)); + if (i < tiers.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→")); + }); + return wrap; + } + return el("span", null, ""); + } + + function refreshSuggestion() { + const rNum = ruleNumber.value.trim(); + const r = state.rulesByNumber[rNum]; + if (!r) { suggestionBox.style.display = "none"; return; } + suggestionBox.style.display = "block"; + suggestionBox.innerHTML = ""; + suggestionBox.appendChild(el("div", { class: "rule-head" }, + el("span", { class: "rule-num" }, r.number), + el("span", { class: "rule-text" }, r.text), + )); + suggestionBox.appendChild(el("div", { class: "kv" }, + el("span", { class: "k" }, t("suggested_penalty")), + el("span", { class: "badge accent" }, r.suggested_penalty), + )); + suggestionBox.appendChild(el("div", { class: "kv" }, + el("span", { class: "k" }, t("escalation")), + escalationViz(r), + )); + } + + function refreshPrior() { + const rNum = ruleNumber.value.trim(); + const pNum = pilotSelect.value; + if (!rNum || !pNum) { priorBox.style.display = "none"; return; } + const prior = state.penalties.filter((x) => + x.pilot_number === pNum && x.rule_number === rNum && x.id !== p.id + ); + priorBox.innerHTML = ""; + if (prior.length === 0) { priorBox.style.display = "none"; return; } + priorBox.style.display = "block"; + priorBox.appendChild(el("div", { class: "prior-title" }, + t("prior_penalties") + " (" + prior.length + ")" + )); + const tbl = el("table", { class: "mini-table" }); + tbl.appendChild(el("thead", null, el("tr", null, + el("th", null, t("flight")), + el("th", null, t("date")), + el("th", null, t("penalty_values")), + el("th", null, t("description")), + ))); + const tb = el("tbody"); + prior.sort((a, b) => naturalCompare(a.date, b.date) || (a.id - b.id)); + for (const pp of prior) { + tb.appendChild(el("tr", null, + el("td", null, pp.flight), + el("td", null, pp.date), + el("td", null, el("span", { class: "badge accent" }, pp.penalties_text)), + el("td", null, pp.description), + )); + } + tbl.appendChild(tb); + priorBox.appendChild(tbl); + } + + refreshSuggestion(); + refreshPrior(); + + ruleNumber.addEventListener("input", () => { refreshSuggestion(); refreshPrior(); }); + + function searchRules(q) { + q = q.trim().toLowerCase(); + if (!q) return []; + return state.rules.filter((r) => + r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q) + ).slice(0, 30); + } + ruleSearch.addEventListener("input", () => { + const items = searchRules(ruleSearch.value); + searchResults.innerHTML = ""; + if (items.length === 0) { searchResults.style.display = "none"; return; } + searchResults.style.display = "block"; + for (const r of items) { + const it = el("div", { class: "item", + onclick: () => { + ruleNumber.value = r.number; + ruleSearch.value = `${r.number} — ${r.text}`; + searchResults.style.display = "none"; + refreshSuggestion(); + refreshPrior(); + } + }, + el("span", { class: "num" }, r.number), + r.text + ); + searchResults.appendChild(it); + } + }); + ruleSearch.addEventListener("blur", () => setTimeout(() => searchResults.style.display = "none", 200)); + + const task = el("input", { type: "text", value: p.task || "" }); + const penaltiesText = el("input", { type: "text", value: p.penalties_text || "", + placeholder: "e.g. 50 CP, Warning, +50m, No Result" }); + const description = el("textarea", null, p.description || ""); + const transferred = el("input", { type: "checkbox", checked: !!p.transferred }); + + return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, + el("div", { class: "modal" }, + el("h3", null, p.id ? t("edit") : t("add_penalty")), + el("div", { class: "grid" }, + el("div", { class: "field" }, el("label", null, t("flight")), flight), + el("div", { class: "field" }, el("label", null, t("date")), date), + el("div", { class: "field" }, el("label", null, t("pilot_number")), pilotSelect), + ), + el("div", { class: "field", style: { marginTop: "0.5rem" } }, + el("label", null, t("search_rule")), + el("div", { class: "search-box" }, ruleSearch, searchResults), + ), + el("div", { class: "field" }, el("label", null, t("rule_number_short")), ruleNumber), + suggestionBox, + priorBox, + el("div", { class: "grid", style: { marginTop: "0.5rem" } }, + el("div", { class: "field" }, el("label", null, t("task")), task), + el("div", { class: "field" }, el("label", null, t("penalty_values")), penaltiesText), + ), + el("div", { class: "field" }, el("label", null, t("description")), description), + el("label", { class: "row", style: { marginTop: "0.5rem" } }, transferred, " " + t("transferred")), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: closeAll }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + const body = { + flight: flight.value, + date: date.value, + pilot_number: pilotSelect.value, + rule_number: ruleNumber.value, + task: task.value, + penalties_text: penaltiesText.value, + description: description.value, + transferred: transferred.checked, + }; + try { + if (p.id) { + await API.updatePenalty(state.competition.id, p.id, body); + } else { + await API.createPenalty(state.competition.id, body); + } + closeAll(); + } catch (e) { alert(e.message); } + } }, t("save")), + ), + ) + ); +} + +const PILOT_COLS = [ + { key: "number", label: "number" }, + { key: "last_name", label: "last_name" }, + { key: "first_name", label: "first_name" }, + { key: "country", label: "country" }, + { key: "balloon_id", label: "balloon_id" }, +]; + +function naturalCompare(a, b) { + if (a == null) a = ""; + if (b == null) b = ""; + return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" }); +} + +function sortPilots(list) { + const { col, dir } = state.pilotSort; + const sign = dir === "asc" ? 1 : -1; + return [...list].sort((a, b) => naturalCompare(a[col], b[col]) * sign); +} + +function renderPilotsTab() { + const card = el("div"); + if (isChief()) { + card.appendChild(el("div", { class: "toolbar" }, + el("button", { class: "primary", onclick: () => openPilotModal() }, t("add_pilot")), + el("button", { onclick: () => openImportModal() }, t("import_csv")), + )); + } + if (state.pilots.length === 0) { + card.appendChild(el("div", { class: "muted" }, t("no_pilots"))); + return card; + } + const table = el("table"); + const headRow = el("tr"); + for (const c of PILOT_COLS) { + headRow.appendChild(el("th", { + onclick: () => { + if (state.pilotSort.col === c.key) state.pilotSort.dir = state.pilotSort.dir === "asc" ? "desc" : "asc"; + else { state.pilotSort.col = c.key; state.pilotSort.dir = "asc"; } + render(); + } + }, + t(c.label), + el("span", { class: "sort-ind" }, state.pilotSort.col === c.key ? (state.pilotSort.dir === "asc" ? "▲" : "▼") : ""), + )); + } + if (isChief()) headRow.appendChild(el("th", null, t("actions"))); + table.appendChild(el("thead", null, headRow)); + const tbody = el("tbody"); + const sorted = sortPilots(state.pilots); + for (const pl of sorted) { + tbody.appendChild(el("tr", null, + el("td", null, pl.number), + el("td", null, pl.last_name), + el("td", null, pl.first_name), + el("td", null, pl.country), + el("td", null, pl.balloon_id), + isChief() && el("td", null, + el("button", { class: "action-btn", onclick: () => openPilotModal(pl) }, t("edit")), + el("button", { class: "action-btn danger", onclick: async () => { + if (!confirm(t("confirm_delete"))) return; + await API.deletePilot(state.competition.id, pl.id); + await loadPilots(); + render(); + } }, t("delete")), + ), + )); + } + table.appendChild(tbody); + card.appendChild(el("div", { class: "table-wrap" }, table)); + return card; +} + +function openPilotModal(pilot) { + state.showPilotModal = true; + state.editingPilot = pilot || null; + render(); +} + +function renderPilotModal() { + const p = state.editingPilot || {}; + const number = el("input", { type: "text", value: p.number || "" }); + const lastName = el("input", { type: "text", value: p.last_name || "" }); + const firstName = el("input", { type: "text", value: p.first_name || "" }); + const country = el("input", { type: "text", value: p.country || "" }); + const balloon = el("input", { type: "text", value: p.balloon_id || "" }); + return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, + el("div", { class: "modal" }, + el("h3", null, p.id ? t("edit") : t("add_pilot")), + el("div", { class: "grid" }, + el("div", { class: "field" }, el("label", null, t("number")), number), + el("div", { class: "field" }, el("label", null, t("last_name")), lastName), + el("div", { class: "field" }, el("label", null, t("first_name")), firstName), + el("div", { class: "field" }, el("label", null, t("country")), country), + el("div", { class: "field" }, el("label", null, t("balloon_id")), balloon), + ), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: closeAll }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + const body = { + number: number.value.trim(), + last_name: lastName.value.trim(), + first_name: firstName.value.trim(), + country: country.value.trim(), + balloon_id: balloon.value.trim(), + }; + try { + if (p.id) await API.updatePilot(state.competition.id, p.id, body); + else await API.createPilot(state.competition.id, body); + await loadPilots(); + closeAll(); + } catch (e) { alert(e.message); } + } }, t("save")), + ), + ) + ); +} + +function openImportModal() { state.showImportModal = true; render(); } + +function renderImportModal() { + const textarea = el("textarea", { placeholder: t("csv_paste"), style: { width: "100%", minHeight: "180px" } }); + return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) { state.showImportModal = false; render(); } } }, + el("div", { class: "modal" }, + el("h3", null, t("import_csv")), + textarea, + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: () => { state.showImportModal = false; render(); } }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + try { + await API.importPilots(state.competition.id, textarea.value); + await loadPilots(); + state.showImportModal = false; render(); + } catch (e) { alert(e.message); } + } }, t("import_csv")), + ) + ) + ); +} + +function ruleEscalationViz(r) { + if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same")); + if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled")); + if (r.escalation_mode === "escalate") { + const wrap = el("div", { class: "tier-row" }); + const tiers = r.escalation_tiers || []; + tiers.forEach((tier, i) => { + wrap.appendChild(el("span", { class: "tier-pill" }, tier)); + if (i < tiers.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→")); + }); + return wrap; + } + return el("span", null, ""); +} + +function renderRulesTab() { + const card = el("div"); + const search = el("input", { type: "search", placeholder: t("search_rule"), style: { width: "100%" } }); + const count = el("div", { class: "muted", style: { margin: "0.5rem 0" } }); + const list = el("div", { class: "rules-list" }); + + function refresh() { + list.innerHTML = ""; + const q = search.value.trim().toLowerCase(); + const filtered = q + ? state.rules.filter((r) => r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q)) + : state.rules; + filtered.sort((a, b) => naturalCompare(a.number, b.number)); + count.textContent = t("showing_n_of_m", { n: filtered.length, m: state.rules.length }); + for (const r of filtered) { + list.appendChild(el("div", { class: "rule-card" }, + el("div", { class: "rule-head" }, + el("span", { class: "rule-num" }, r.number), + el("span", { class: "rule-text" }, r.text), + ), + el("div", { class: "kv" }, + el("span", { class: "k" }, t("suggested_penalty")), + el("span", { class: "badge accent" }, r.suggested_penalty), + ), + el("div", { class: "kv" }, + el("span", { class: "k" }, t("escalation")), + ruleEscalationViz(r), + ), + )); + } + if (filtered.length === 0) list.appendChild(el("div", { class: "muted" }, t("none"))); + } + search.addEventListener("input", refresh); + card.appendChild(search); + card.appendChild(count); + card.appendChild(list); + setTimeout(refresh, 0); + return card; +} + +function renderMembersTab() { + const card = el("div"); + card.appendChild(el("div", { class: "toolbar" }, + el("button", { class: "primary", onclick: () => openMemberModal() }, t("add_member")), + state.user.is_system_admin && el("button", { onclick: () => openUserModal(state.competition.id) }, t("add_user")), + )); + if (state.members.length === 0) { + card.appendChild(el("div", { class: "muted" }, t("no_members"))); + 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("role")), + el("th", null, t("actions")), + ))); + const tbody = el("tbody"); + for (const m of state.members) { + tbody.appendChild(el("tr", null, + el("td", null, m.username), + el("td", null, m.display_name), + el("td", null, t(m.role)), + el("td", null, + el("button", { class: "action-btn danger", onclick: async () => { + if (!confirm(t("confirm_delete"))) return; + await API.removeMember(state.competition.id, m.user_id); + await loadMembers(); + render(); + } }, t("remove")), + ), + )); + } + table.appendChild(tbody); + card.appendChild(el("div", { class: "table-wrap" }, table)); + return card; +} + +function openMemberModal() { + state.showMemberModal = true; + render(); +} + +function renderMemberModal() { + const username = el("input", { type: "text", placeholder: t("username") }); + const roleSelect = el("select", null, + el("option", { value: "scorer" }, t("scorer")), + el("option", { value: "chief_scorer" }, t("chief_scorer")), + ); + return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, + el("div", { class: "modal" }, + el("h3", null, t("add_member")), + el("p", { class: "muted" }, t("username")), + username, + el("div", { class: "field", style: { marginTop: "0.5rem" } }, el("label", null, t("role")), roleSelect), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: closeAll }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + let users = []; + try { users = await API.listUsers(); } catch (e) { alert(t("forbidden")); return; } + const u = users.find((x) => x.username === username.value.trim()); + if (!u) { alert("User not found"); return; } + await API.addMember(state.competition.id, { user_id: u.id, role: roleSelect.value }); + await loadMembers(); + closeAll(); + } }, t("save")), + ), + ) + ); +} + +function renderSettingsTab() { + const name = el("input", { type: "text", value: state.competition.name }); + const allow = el("input", { type: "checkbox", checked: !!state.competition.allow_any_scorer_edit }); + const msg = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } }); + return el("div", { class: "card" }, + el("h2", null, t("settings_tab")), + el("div", { class: "field" }, el("label", null, t("competition_name")), name), + el("label", { class: "row", style: { marginTop: "0.5rem" } }, allow, " " + t("allow_any_scorer_edit")), + msg, + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { class: "primary", onclick: async () => { + await API.updateCompetition(state.competition.id, { + name: name.value, allow_any_scorer_edit: allow.checked, + }); + const c = await API.getCompetition(state.competition.id); + state.competition = c; + msg.textContent = t("saved"); + msg.style.display = "block"; + } }, t("save_settings")), + ), + ); +} + +async function init() { + const userLang = navigator.language ? navigator.language.slice(0, 2) : "en"; + setLang(I18N_AVAILABLE.includes(userLang) ? userLang : "en"); + try { + const u = await API.me(); + state.user = u; + setLang(u.language); + await loadCompetitions(); + if (u.is_system_admin) await loadUsers(); + } catch (e) { + state.user = null; + } + render(); +} + +init(); diff --git a/web/config.js b/web/config.js new file mode 100644 index 0000000..45219fb --- /dev/null +++ b/web/config.js @@ -0,0 +1,24 @@ +const API_BASE = "http://127.0.0.1:8080"; + +function getApiBase() { + return API_BASE; +} + +function apiURL(path) { + const base = getApiBase(); + if (!base) return path; + return base + path; +} + +function wsURL(path) { + const base = getApiBase(); + if (base) { + let u; + try { u = new URL(base); } catch (e) { return null; } + u.protocol = u.protocol === "https:" ? "wss:" : "ws:"; + u.pathname = u.pathname.replace(/\/+$/, "") + path; + return u.toString(); + } + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + return `${proto}//${location.host}${path}`; +} diff --git a/web/i18n.js b/web/i18n.js new file mode 100644 index 0000000..e0a6a6c --- /dev/null +++ b/web/i18n.js @@ -0,0 +1,323 @@ +const I18N_AVAILABLE = ["en", "de", "pl", "ru", "fr", "es", "pt"]; +const I18N_NAMES = { + en: "English", de: "Deutsch", pl: "Polski", ru: "Русский", + fr: "Français", es: "Español", pt: "Português" +}; + +const I18N_DATA = { + en: { + login_title: "Sign in", + username: "Username", password: "Password", sign_in: "Sign in", + invalid_credentials: "Invalid username or password", + logout: "Logout", settings: "Settings", language: "Language", + competitions: "Competitions", new_competition: "New competition", + competition_name: "Competition name", create: "Create", cancel: "Cancel", + role: "Role", system_admin: "System Admin", chief_scorer: "Chief Scorer", scorer: "Scorer", + pilots: "Pilots", penalties: "Penalties", members: "Members", rules: "Rules", + settings_tab: "Settings", + number: "Number", last_name: "Last name", first_name: "First name", + country: "Country", balloon_id: "Balloon ID", + add_pilot: "Add pilot", import_csv: "Import CSV", export_csv: "Export CSV", + flight: "Flight", date: "Date", pilot_number: "Pilot No.", + pilot_name: "Pilot name", rule: "Rule", task: "Task", + penalty_values: "Penalties", description: "Description", created_by: "Created by", + transferred: "Transferred", actions: "Actions", + add_penalty: "Add penalty", edit: "Edit", delete: "Delete", save: "Save", + confirm_delete: "Delete this entry?", search_rule: "Search rule by number or text", + suggested_penalty: "Suggested penalty", + escalation: "Escalation behavior", + escalation_same: "Stays the same", escalation_doubled: "Doubled each time", + escalation_escalate: "Escalates: ", + add_member: "Add member", remove: "Remove", + add_user: "Add user", users: "Users", + display_name: "Display name", is_admin: "Admin", + allow_any_scorer_edit: "Allow any scorer to edit penalties", + open: "Open", back: "Back", change_password: "Change password", + new_password: "New password", csv_paste: "Paste CSV (number,last,first,country,balloon)", + no_pilots: "No pilots yet", no_penalties: "No penalties yet", + no_members: "No members yet", no_competitions: "No competitions", + select_pilot: "Select pilot", rule_number_short: "Rule No.", + transferred_only: "Only untransferred", + showing_n_of_m: "Showing {n} of {m}", + online: "Online", offline: "Offline", + forbidden: "Not allowed", + save_settings: "Save settings", + saved: "Saved", + yes: "Yes", no: "No", + backend_url: "Backend URL", + backend_url_hint: "Leave empty to use the same origin (e.g. http://192.168.0.10:8080)", + profile: "Profile", + current_password: "Current password", + leave_blank_keep: "Leave blank to keep current", + username_taken: "Username already taken", + prior_penalties: "Prior penalties for this pilot and rule", + none: "None", + }, + de: { + login_title: "Anmelden", + username: "Benutzername", password: "Passwort", sign_in: "Anmelden", + invalid_credentials: "Falscher Benutzername oder Passwort", + logout: "Abmelden", settings: "Einstellungen", language: "Sprache", + competitions: "Wettbewerbe", new_competition: "Neuer Wettbewerb", + competition_name: "Wettbewerbsname", create: "Erstellen", cancel: "Abbrechen", + role: "Rolle", system_admin: "Systemadmin", chief_scorer: "Chief-Scorer", scorer: "Scorer", + pilots: "Piloten", penalties: "Strafen", members: "Mitglieder", rules: "Regeln", + settings_tab: "Einstellungen", + number: "Nummer", last_name: "Nachname", first_name: "Vorname", + country: "Land", balloon_id: "Ballon-Kennung", + add_pilot: "Pilot hinzufügen", import_csv: "CSV importieren", export_csv: "CSV exportieren", + flight: "Fahrt", date: "Datum", pilot_number: "Pilot-Nr.", + pilot_name: "Pilotenname", rule: "Regel", task: "Aufgabe", + penalty_values: "Strafen", description: "Beschreibung", created_by: "Angelegt von", + transferred: "Übertragen", actions: "Aktionen", + add_penalty: "Strafe hinzufügen", edit: "Bearbeiten", delete: "Löschen", save: "Speichern", + confirm_delete: "Eintrag löschen?", search_rule: "Regel nach Nummer oder Text suchen", + suggested_penalty: "Vorgeschlagene Strafe", + escalation: "Verhalten bei Wiederholung", + escalation_same: "Bleibt gleich", escalation_doubled: "Wird jedes Mal verdoppelt", + escalation_escalate: "Höherstufung: ", + add_member: "Mitglied hinzufügen", remove: "Entfernen", + add_user: "Benutzer anlegen", users: "Benutzer", + display_name: "Anzeigename", is_admin: "Admin", + allow_any_scorer_edit: "Alle Scorer dürfen Strafen bearbeiten", + open: "Öffnen", back: "Zurück", change_password: "Passwort ändern", + new_password: "Neues Passwort", csv_paste: "CSV einfügen (Nr,Nachname,Vorname,Land,Ballon)", + no_pilots: "Noch keine Piloten", no_penalties: "Noch keine Strafen", + no_members: "Noch keine Mitglieder", no_competitions: "Keine Wettbewerbe", + select_pilot: "Pilot wählen", rule_number_short: "Regel-Nr.", + transferred_only: "Nur nicht übertragene", + showing_n_of_m: "{n} von {m}", + online: "Online", offline: "Offline", + forbidden: "Keine Berechtigung", + save_settings: "Einstellungen speichern", + saved: "Gespeichert", + yes: "Ja", no: "Nein", + backend_url: "Backend-URL", + backend_url_hint: "Leer lassen für gleichen Ursprung (z.B. http://192.168.0.10:8080)", + profile: "Profil", + current_password: "Aktuelles Passwort", + leave_blank_keep: "Leer lassen um beizubehalten", + username_taken: "Benutzername bereits vergeben", + prior_penalties: "Frühere Strafen für diesen Piloten und diese Regel", + none: "Keine", + }, + pl: { + login_title: "Zaloguj się", + username: "Nazwa użytkownika", password: "Hasło", sign_in: "Zaloguj", + invalid_credentials: "Nieprawidłowy login lub hasło", + logout: "Wyloguj", settings: "Ustawienia", language: "Język", + competitions: "Zawody", new_competition: "Nowe zawody", + competition_name: "Nazwa zawodów", create: "Utwórz", cancel: "Anuluj", + role: "Rola", system_admin: "Administrator", chief_scorer: "Chief-Scorer", scorer: "Scorer", + pilots: "Piloci", penalties: "Kary", members: "Członkowie", rules: "Zasady", + settings_tab: "Ustawienia", + number: "Numer", last_name: "Nazwisko", first_name: "Imię", + country: "Kraj", balloon_id: "Oznaczenie balonu", + add_pilot: "Dodaj pilota", import_csv: "Import CSV", export_csv: "Eksport CSV", + flight: "Lot", date: "Data", pilot_number: "Nr pilota", + pilot_name: "Imię i nazwisko", rule: "Zasada", task: "Zadanie", + penalty_values: "Kary", description: "Opis", created_by: "Wprowadził", + transferred: "Przesłano", actions: "Akcje", + add_penalty: "Dodaj karę", edit: "Edytuj", delete: "Usuń", save: "Zapisz", + confirm_delete: "Usunąć ten wpis?", search_rule: "Szukaj zasady po numerze lub tekście", + suggested_penalty: "Sugerowana kara", + escalation: "Zachowanie przy powtórzeniu", + escalation_same: "Bez zmian", escalation_doubled: "Podwajana za każdym razem", + escalation_escalate: "Eskalacja: ", + add_member: "Dodaj członka", remove: "Usuń", + add_user: "Dodaj użytkownika", users: "Użytkownicy", + display_name: "Wyświetlana nazwa", is_admin: "Admin", + allow_any_scorer_edit: "Pozwól dowolnemu scorerowi edytować kary", + open: "Otwórz", back: "Wstecz", change_password: "Zmień hasło", + new_password: "Nowe hasło", csv_paste: "Wklej CSV (nr,nazwisko,imię,kraj,balon)", + no_pilots: "Brak pilotów", no_penalties: "Brak kar", + no_members: "Brak członków", no_competitions: "Brak zawodów", + select_pilot: "Wybierz pilota", rule_number_short: "Nr zasady", + transferred_only: "Tylko nieprzesłane", + showing_n_of_m: "{n} z {m}", + online: "Online", offline: "Offline", + forbidden: "Brak uprawnień", + save_settings: "Zapisz ustawienia", + saved: "Zapisano", + yes: "Tak", no: "Nie", + }, + ru: { + login_title: "Вход", + username: "Имя пользователя", password: "Пароль", sign_in: "Войти", + invalid_credentials: "Неверное имя или пароль", + logout: "Выйти", settings: "Настройки", language: "Язык", + competitions: "Соревнования", new_competition: "Новое соревнование", + competition_name: "Название", create: "Создать", cancel: "Отмена", + role: "Роль", system_admin: "Администратор", chief_scorer: "Chief-Scorer", scorer: "Scorer", + pilots: "Пилоты", penalties: "Штрафы", members: "Участники", rules: "Правила", + settings_tab: "Настройки", + number: "Номер", last_name: "Фамилия", first_name: "Имя", + country: "Страна", balloon_id: "Регистрация шара", + add_pilot: "Добавить пилота", import_csv: "Импорт CSV", export_csv: "Экспорт CSV", + flight: "Полёт", date: "Дата", pilot_number: "№ пилота", + pilot_name: "Имя пилота", rule: "Правило", task: "Задание", + penalty_values: "Штрафы", description: "Описание", created_by: "Автор", + transferred: "Передано", actions: "Действия", + add_penalty: "Добавить штраф", edit: "Редактировать", delete: "Удалить", save: "Сохранить", + confirm_delete: "Удалить запись?", search_rule: "Поиск правила по номеру или тексту", + suggested_penalty: "Рекомендованный штраф", + escalation: "Поведение при повторе", + escalation_same: "Без изменений", escalation_doubled: "Удваивается каждый раз", + escalation_escalate: "Эскалация: ", + add_member: "Добавить участника", remove: "Удалить", + add_user: "Создать пользователя", users: "Пользователи", + display_name: "Отображаемое имя", is_admin: "Админ", + allow_any_scorer_edit: "Любой Scorer может редактировать штрафы", + open: "Открыть", back: "Назад", change_password: "Изменить пароль", + new_password: "Новый пароль", csv_paste: "Вставьте CSV (№,фамилия,имя,страна,шар)", + no_pilots: "Нет пилотов", no_penalties: "Нет штрафов", + no_members: "Нет участников", no_competitions: "Нет соревнований", + select_pilot: "Выберите пилота", rule_number_short: "№ правила", + transferred_only: "Только непереданные", + showing_n_of_m: "{n} из {m}", + online: "Онлайн", offline: "Оффлайн", + forbidden: "Нет доступа", + save_settings: "Сохранить настройки", + saved: "Сохранено", + yes: "Да", no: "Нет", + }, + fr: { + login_title: "Connexion", + username: "Nom d'utilisateur", password: "Mot de passe", sign_in: "Connexion", + invalid_credentials: "Identifiants invalides", + logout: "Déconnexion", settings: "Paramètres", language: "Langue", + competitions: "Compétitions", new_competition: "Nouvelle compétition", + competition_name: "Nom", create: "Créer", cancel: "Annuler", + role: "Rôle", system_admin: "Administrateur", chief_scorer: "Chief-Scorer", scorer: "Scorer", + pilots: "Pilotes", penalties: "Pénalités", members: "Membres", rules: "Règles", + settings_tab: "Paramètres", + number: "Numéro", last_name: "Nom", first_name: "Prénom", + country: "Pays", balloon_id: "Immat. ballon", + add_pilot: "Ajouter un pilote", import_csv: "Importer CSV", export_csv: "Exporter CSV", + flight: "Vol", date: "Date", pilot_number: "N° pilote", + pilot_name: "Nom du pilote", rule: "Règle", task: "Épreuve", + penalty_values: "Pénalités", description: "Description", created_by: "Créé par", + transferred: "Transféré", actions: "Actions", + add_penalty: "Ajouter pénalité", edit: "Modifier", delete: "Supprimer", save: "Enregistrer", + confirm_delete: "Supprimer cette entrée ?", search_rule: "Rechercher une règle par numéro ou texte", + suggested_penalty: "Pénalité suggérée", + escalation: "Comportement en cas de répétition", + escalation_same: "Reste identique", escalation_doubled: "Doublée à chaque fois", + escalation_escalate: "Escalade : ", + add_member: "Ajouter membre", remove: "Retirer", + add_user: "Créer un utilisateur", users: "Utilisateurs", + display_name: "Nom affiché", is_admin: "Admin", + allow_any_scorer_edit: "Tous les scorers peuvent modifier les pénalités", + open: "Ouvrir", back: "Retour", change_password: "Changer le mot de passe", + new_password: "Nouveau mot de passe", csv_paste: "Coller CSV (n°,nom,prénom,pays,ballon)", + no_pilots: "Aucun pilote", no_penalties: "Aucune pénalité", + no_members: "Aucun membre", no_competitions: "Aucune compétition", + select_pilot: "Choisir un pilote", rule_number_short: "N° règle", + transferred_only: "Non transférés uniquement", + showing_n_of_m: "{n} sur {m}", + online: "En ligne", offline: "Hors ligne", + forbidden: "Non autorisé", + save_settings: "Enregistrer", + saved: "Enregistré", + yes: "Oui", no: "Non", + }, + es: { + login_title: "Iniciar sesión", + username: "Usuario", password: "Contraseña", sign_in: "Entrar", + invalid_credentials: "Usuario o contraseña incorrectos", + logout: "Salir", settings: "Ajustes", language: "Idioma", + competitions: "Competiciones", new_competition: "Nueva competición", + competition_name: "Nombre", create: "Crear", cancel: "Cancelar", + role: "Rol", system_admin: "Administrador", chief_scorer: "Chief-Scorer", scorer: "Scorer", + pilots: "Pilotos", penalties: "Penalizaciones", members: "Miembros", rules: "Reglas", + settings_tab: "Ajustes", + number: "Número", last_name: "Apellido", first_name: "Nombre", + country: "País", balloon_id: "Matrícula globo", + add_pilot: "Añadir piloto", import_csv: "Importar CSV", export_csv: "Exportar CSV", + flight: "Vuelo", date: "Fecha", pilot_number: "N.º piloto", + pilot_name: "Nombre piloto", rule: "Regla", task: "Tarea", + penalty_values: "Penalizaciones", description: "Descripción", created_by: "Creado por", + transferred: "Transferido", actions: "Acciones", + add_penalty: "Añadir penalización", edit: "Editar", delete: "Eliminar", save: "Guardar", + confirm_delete: "¿Eliminar este registro?", search_rule: "Buscar regla por número o texto", + suggested_penalty: "Penalización sugerida", + escalation: "Comportamiento al repetirse", + escalation_same: "Sin cambios", escalation_doubled: "Se duplica cada vez", + escalation_escalate: "Escala: ", + add_member: "Añadir miembro", remove: "Quitar", + add_user: "Crear usuario", users: "Usuarios", + display_name: "Nombre mostrado", is_admin: "Admin", + allow_any_scorer_edit: "Cualquier scorer puede editar penalizaciones", + open: "Abrir", back: "Atrás", change_password: "Cambiar contraseña", + new_password: "Nueva contraseña", csv_paste: "Pegar CSV (n.º,apellido,nombre,país,globo)", + no_pilots: "Sin pilotos", no_penalties: "Sin penalizaciones", + no_members: "Sin miembros", no_competitions: "Sin competiciones", + select_pilot: "Elegir piloto", rule_number_short: "N.º regla", + transferred_only: "Solo no transferidas", + showing_n_of_m: "{n} de {m}", + online: "En línea", offline: "Sin conexión", + forbidden: "No permitido", + save_settings: "Guardar ajustes", + saved: "Guardado", + yes: "Sí", no: "No", + }, + pt: { + login_title: "Entrar", + username: "Utilizador", password: "Palavra-passe", sign_in: "Entrar", + invalid_credentials: "Utilizador ou palavra-passe inválidos", + logout: "Sair", settings: "Definições", language: "Idioma", + competitions: "Competições", new_competition: "Nova competição", + competition_name: "Nome", create: "Criar", cancel: "Cancelar", + role: "Papel", system_admin: "Administrador", chief_scorer: "Chief-Scorer", scorer: "Scorer", + pilots: "Pilotos", penalties: "Penalizações", members: "Membros", rules: "Regras", + settings_tab: "Definições", + number: "Número", last_name: "Apelido", first_name: "Nome", + country: "País", balloon_id: "Matrícula balão", + add_pilot: "Adicionar piloto", import_csv: "Importar CSV", export_csv: "Exportar CSV", + flight: "Voo", date: "Data", pilot_number: "N.º piloto", + pilot_name: "Nome do piloto", rule: "Regra", task: "Tarefa", + penalty_values: "Penalizações", description: "Descrição", created_by: "Criado por", + transferred: "Transferido", actions: "Ações", + add_penalty: "Adicionar penalização", edit: "Editar", delete: "Eliminar", save: "Guardar", + confirm_delete: "Eliminar este registo?", search_rule: "Procurar regra por número ou texto", + suggested_penalty: "Penalização sugerida", + escalation: "Comportamento em caso de repetição", + escalation_same: "Sem alteração", escalation_doubled: "Duplicada de cada vez", + escalation_escalate: "Escala: ", + add_member: "Adicionar membro", remove: "Remover", + add_user: "Criar utilizador", users: "Utilizadores", + display_name: "Nome mostrado", is_admin: "Admin", + allow_any_scorer_edit: "Qualquer scorer pode editar penalizações", + open: "Abrir", back: "Voltar", change_password: "Alterar palavra-passe", + new_password: "Nova palavra-passe", csv_paste: "Colar CSV (n.º,apelido,nome,país,balão)", + no_pilots: "Sem pilotos", no_penalties: "Sem penalizações", + no_members: "Sem membros", no_competitions: "Sem competições", + select_pilot: "Escolher piloto", rule_number_short: "N.º regra", + transferred_only: "Apenas não transferidas", + showing_n_of_m: "{n} de {m}", + online: "Online", offline: "Offline", + forbidden: "Não autorizado", + save_settings: "Guardar definições", + saved: "Guardado", + yes: "Sim", no: "Não", + }, +}; + +let CURRENT_LANG = "en"; + +function setLang(lang) { + if (!I18N_DATA[lang]) lang = "en"; + CURRENT_LANG = lang; + document.documentElement.lang = lang; +} + +function t(key, vars) { + const d = I18N_DATA[CURRENT_LANG] || I18N_DATA.en; + let str = d[key] || I18N_DATA.en[key] || key; + if (vars) { + for (const k in vars) { + str = str.replaceAll("{" + k + "}", vars[k]); + } + } + return str; +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..870049e --- /dev/null +++ b/web/index.html @@ -0,0 +1,16 @@ + + + + + +Penalty Tracker + + + +
+ + + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..3bc5fa4 --- /dev/null +++ b/web/style.css @@ -0,0 +1,399 @@ +:root { + --accent: #2b6cb0; + --accent-hover: #2c5282; + --bg: #ffffff; + --fg: #111111; + --muted: #6b7280; + --border: #e5e7eb; + --row-hover: #f9fafb; + --danger: #b91c1c; + --radius: 0.375rem; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--fg); + font-size: 14px; + line-height: 1.45; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-hover); text-decoration: underline; } + +button, input, select, textarea { + font-family: inherit; + font-size: inherit; + color: inherit; + border-radius: var(--radius); + border: 1px solid var(--border); + background: #fff; + padding: 0.4rem 0.6rem; + outline: none; +} + +button { + cursor: pointer; + background: #fff; + transition: background 0.1s ease, border-color 0.1s ease; +} +button:hover { border-color: var(--accent); } +button.primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +button.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } +button.danger { + background: #fff; + color: var(--danger); + border-color: var(--border); +} +button.danger:hover { border-color: var(--danger); } +button.ghost { border-color: transparent; background: transparent; } + +input:focus, select:focus, textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(43, 108, 176, 0.15); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.25rem; + border-bottom: 1px solid var(--border); + background: #fff; + position: sticky; + top: 0; + z-index: 10; +} +.topbar .brand { + font-weight: 600; + font-size: 1rem; + color: var(--accent); +} +.topbar .nav { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.container { + padding: 1.25rem; + max-width: 1400px; + margin: 0 auto; +} + +.card { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + background: #fff; + margin-bottom: 1rem; +} + +.card h2 { + margin: 0 0 0.75rem 0; + font-size: 1rem; + font-weight: 600; +} + +.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; } +.col { display: flex; flex-direction: column; gap: 0.5rem; } + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.field { display: flex; flex-direction: column; gap: 0.25rem; } +.field label { font-size: 0.8rem; color: var(--muted); } + +.login-wrap { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 1rem; +} +.login-box { + width: 100%; + max-width: 360px; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + background: #fff; +} +.login-box h1 { + margin: 0 0 1rem 0; + font-size: 1.125rem; + color: var(--accent); +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +th, td { + text-align: left; + padding: 0.5rem 0.6rem; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +th { + font-weight: 600; + background: #fafafa; + position: sticky; + top: 0; + cursor: pointer; + user-select: none; + white-space: nowrap; +} +th .sort-ind { + color: var(--muted); + font-size: 0.7rem; + margin-left: 0.25rem; +} +tbody tr:hover { background: var(--row-hover); } +tbody tr.transferred { background: #f3f6fb; } + +.table-wrap { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: auto; + max-height: 70vh; +} + +.badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: var(--radius); + font-size: 0.75rem; + background: #f3f4f6; + color: #374151; + border: 1px solid var(--border); +} +.badge.accent { background: rgba(43, 108, 176, 0.1); color: var(--accent); border-color: rgba(43, 108, 176, 0.3); } +.badge.warn { background: #fff7ed; color: #9a3412; border-color: #fed7aa; } + +.muted { color: var(--muted); font-size: 0.85rem; } + +.modal-backdrop { + position: fixed; inset: 0; + background: rgba(0,0,0,0.4); + display: flex; align-items: center; justify-content: center; + z-index: 50; + padding: 1rem; +} +.modal { + background: #fff; + border-radius: var(--radius); + padding: 1.25rem; + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow: auto; +} +.modal h3 { margin-top: 0; } + +.toolbar { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; flex-wrap: wrap; } +.toolbar .spacer { flex: 1; } + +.rule-suggestion { + background: #f9fafb; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; + font-size: 0.85rem; +} + +.rule-card { + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + padding: 0.625rem 0.875rem; + background: #fff; + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.rule-head { + display: flex; + align-items: baseline; + gap: 0.5rem; + flex-wrap: wrap; +} +.rule-num { + color: var(--accent); + font-weight: 700; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85rem; + background: rgba(43, 108, 176, 0.08); + padding: 0.1rem 0.4rem; + border-radius: var(--radius); + white-space: nowrap; +} +.rule-text { font-size: 0.9rem; line-height: 1.3; } +.kv { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + font-size: 0.8rem; +} +.kv .k { color: var(--muted); min-width: 9rem; } +.small { font-size: 0.8rem; } + +.tier-row { + display: inline-flex; + align-items: center; + gap: 0.3rem; + flex-wrap: wrap; +} +.tier-pill { + background: #fff; + border: 1px solid var(--border); + color: var(--fg); + padding: 0.1rem 0.45rem; + border-radius: var(--radius); + font-size: 0.75rem; + font-weight: 500; +} +.tier-pill:last-child { + border-color: var(--accent); + color: var(--accent); + background: rgba(43, 108, 176, 0.08); +} +.tier-arrow { color: var(--muted); font-size: 0.75rem; } + +.rules-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 0.625rem; +} + +.prior-box { + margin-top: 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; + background: #fafbfc; +} +.prior-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--accent); + margin-bottom: 0.4rem; +} +.mini-table { font-size: 12px; } +.mini-table th, .mini-table td { + padding: 0.3rem 0.4rem; + border-bottom: 1px solid var(--border); + background: transparent; +} +.mini-table th { background: transparent; position: static; } + +.search-box { + position: relative; + width: 100%; +} +.search-box .results { + position: absolute; + top: 100%; left: 0; right: 0; + border: 1px solid var(--border); + background: #fff; + border-radius: var(--radius); + max-height: 240px; + overflow: auto; + z-index: 5; + margin-top: 0.25rem; +} +.search-box .results .item { + padding: 0.5rem 0.6rem; + cursor: pointer; + border-bottom: 1px solid var(--border); +} +.search-box .results .item:last-child { border-bottom: none; } +.search-box .results .item:hover { background: #f3f4f6; } +.search-box .results .item .num { color: var(--accent); font-weight: 600; margin-right: 0.5rem; } + +.switch { + position: relative; + display: inline-block; + width: 36px; + height: 20px; +} +.switch input { opacity: 0; width: 0; height: 0; } +.switch .slider { + position: absolute; + cursor: pointer; + inset: 0; + background-color: #d1d5db; + transition: 0.15s; + border-radius: var(--radius); +} +.switch .slider:before { + position: absolute; + content: ""; + height: 14px; + width: 14px; + left: 3px; + top: 3px; + background-color: white; + transition: 0.15s; + border-radius: var(--radius); +} +.switch input:checked + .slider { background-color: var(--accent); } +.switch input:checked + .slider:before { transform: translateX(16px); } + +.tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + border-bottom: 1px solid var(--border); +} +.tabs button { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + padding: 0.5rem 0.75rem; + color: var(--muted); +} +.tabs button.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.action-btn { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + background: transparent; + border: 1px solid transparent; +} +.action-btn:hover { border-color: var(--border); } + +.connection-status { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: #d1d5db; + margin-right: 0.5rem; +} +.connection-status.online { background: #10b981; } +.connection-status.offline { background: #ef4444; } + +textarea { resize: vertical; min-height: 60px; } + +@media (max-width: 700px) { + .topbar { padding: 0.5rem 0.75rem; } + .container { padding: 0.75rem; } +} diff --git a/ws.go b/ws.go new file mode 100644 index 0000000..1d8abf2 --- /dev/null +++ b/ws.go @@ -0,0 +1,130 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + "sync" + + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +type wsMessage struct { + Type string `json:"type"` + Payload any `json:"payload,omitempty"` +} + +type client struct { + conn *websocket.Conn + competitionID int64 + send chan []byte +} + +type Hub struct { + mu sync.RWMutex + clients map[int64]map[*client]struct{} +} + +var hub *Hub + +func newHub() *Hub { + return &Hub{clients: map[int64]map[*client]struct{}{}} +} + +func (h *Hub) run() {} + +func (h *Hub) register(c *client) { + h.mu.Lock() + defer h.mu.Unlock() + set, ok := h.clients[c.competitionID] + if !ok { + set = map[*client]struct{}{} + h.clients[c.competitionID] = set + } + set[c] = struct{}{} +} + +func (h *Hub) unregister(c *client) { + h.mu.Lock() + defer h.mu.Unlock() + if set, ok := h.clients[c.competitionID]; ok { + if _, ok := set[c]; ok { + delete(set, c) + close(c.send) + } + if len(set) == 0 { + delete(h.clients, c.competitionID) + } + } +} + +func (h *Hub) broadcast(competitionID int64, kind string, payload any) { + msg := wsMessage{Type: kind, Payload: payload} + data, err := json.Marshal(msg) + if err != nil { + return + } + h.mu.RLock() + defer h.mu.RUnlock() + set, ok := h.clients[competitionID] + if !ok { + return + } + for c := range set { + select { + case c.send <- data: + default: + } + } +} + +func registerWSRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/competitions/{id}/ws", requireAuth(handleWS)) +} + +func handleWS(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + if _, ok := canAccessCompetition(u, id); !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + c := &client{conn: conn, competitionID: id, send: make(chan []byte, 16)} + hub.register(c) + go writePump(c) + readPump(c) +} + +func readPump(c *client) { + defer func() { + hub.unregister(c) + c.conn.Close() + }() + c.conn.SetReadLimit(1024) + for { + if _, _, err := c.conn.ReadMessage(); err != nil { + return + } + } +} + +func writePump(c *client) { + defer c.conn.Close() + for msg := range c.send { + if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + } +}